diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47957c5..4fa6eb2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,37 @@ All notable changes to `dskripchenko/php-pdf` are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+- HTML lists: `
`, `type` and `list-style-type` (decimal, alpha,
+ roman, `none`), ``, and `::marker { content }` labels with
+ strings, `counter(list-item)` and `counters(list-item, ".")` — Word's
+ multilevel numbering (`3.1.`, `4.1.`) as an import writes it. An item with
+ no label and no content draws only its nested list. `ListItem::$marker`
+ and `ListItem::$value`; `ListFormat::number()`.
+- HTML lists are placed by CSS: a list's `padding-left` / `margin-left`
+ (`ListNode::$indentPt`) and an item's `margin-left` (`ListItem::$indentPt`)
+ instead of the fixed indent per level — Word places a level by its
+ numbering's indent, not by its depth.
+- HTML lists keep the vertical space their CSS gives them: a list's
+ `margin-top` / `margin-bottom` (the 6 pt gap after a list only when the
+ list says nothing), an item's own margins on its paragraphs.
+- `Footnote::$style`: the text style a note is printed in at the foot of
+ the page or among the endnotes — notes are usually smaller than the body.
+- A list in a table cell splits between its items (and inside a nested
+ list) when its row is split across pages; the rest numbers on. Before, a
+ long list carried its whole row to the next page.
+- A hanging indent is a tab stop on a paragraph's first line, as in Word,
+ and a list label is followed by a tab: the item's text starts on the
+ indent whatever the label's width (`1.` or `3.12.`).
+
+### Fixed
+- HTML: `` and `type` were ignored — every list counted from 1
+ in decimal.
+- HTML: a list item holding only a nested list printed an empty line
+ before it.
+
## [1.23.0] — 2026-10-18
### Added
diff --git a/docs/de/USAGE.md b/docs/de/USAGE.md
index 0dfdb2a..bf66138 100644
--- a/docs/de/USAGE.md
+++ b/docs/de/USAGE.md
@@ -252,6 +252,8 @@ new Section($body); // Endnoten nach dem Abschnitt
Mit `footnotesAtPageFoot` verliert eine Seite ohne Fußnoten nichts, und
eine Zeile, deren Fußnoten nicht mehr auf ihre Seite passen, wandert mit
ihnen weiter — so, wie eine Textverarbeitung sie setzt.
+`new Footnote('Text', new RunStyle(sizePt: 8))` legt den eigenen Textstil
+der Fußnote fest.
### Seitenaufbau und Abschnittsumbrüche
@@ -382,6 +384,17 @@ $doc->toFile('report.pdf');
`background-color`, `padding`, `vertical-align`, `width`, `height`,
`colspan`, `rowspan`; Zeilen: `height`, `break-inside: avoid`.
``-Zeilen wiederholen sich auf jeder Seite.
+- Listen: ``, `type` und `list-style-type` (decimal, alpha,
+ roman, `none`), ``; `::marker { content }` mit Strings,
+ `counter(list-item)` und `counters(list-item, ".")` beschriftet die
+ Einträge — `3.1.` unter Eintrag 3, so wie die mehrstufige Nummerierung
+ von Word geschrieben wird; ein Eintrag mit `list-style-type: none` ohne
+ eigenen Inhalt trägt nur seine verschachtelte Liste. `padding-left` /
+ `margin-left` einer Liste und `margin-left` eines Eintrags legen fest, wo
+ der Text der Einträge beginnt; die Beschriftung hängt davor, und ein
+ Tabulator danach setzt den Text auf den Einzug, wie in Word.
+ `margin-top` / `margin-bottom` einer Liste und die Ränder eines Eintrags
+ bestimmen den Abstand um sie herum.
- Bilder: `width` / `height` (`%` der Zeile), `display: block` mit
`margin: auto` zentriert. Ein `` in einer Textzeile wird als eigener
Block gezeichnet: der Absatz wird um ihn geteilt, und er übernimmt die
diff --git a/docs/en/USAGE.md b/docs/en/USAGE.md
index 4f2bf8e..e517ac2 100644
--- a/docs/en/USAGE.md
+++ b/docs/en/USAGE.md
@@ -242,7 +242,8 @@ new Section($body); // endnotes after the section
With `footnotesAtPageFoot` a page without notes loses nothing, and a line
whose notes do not fit on its page moves on with them — the way a word
-processor places them.
+processor places them. `new Footnote('text', new RunStyle(sizePt: 8))` sets
+the note's own text style.
### Page setup and section breaks
@@ -369,6 +370,16 @@ $doc->toFile('report.pdf');
`padding`, `vertical-align`, `width`, `height`, `colspan`, `rowspan`;
rows: `height`, `break-inside: avoid`. `` rows repeat on every
page.
+- Lists: ``, `type` and `list-style-type` (decimal, alpha,
+ roman, `none`), ``; `::marker { content }` with strings,
+ `counter(list-item)` and `counters(list-item, ".")` labels the items —
+ `3.1.` under item 3, the way Word's multilevel numbering is written; an
+ item with `list-style-type: none` and no content of its own only carries
+ its nested list. A list's `padding-left` / `margin-left` and an item's
+ `margin-left` place the items' text; the label hangs in front of it and
+ a tab after it puts the text on the indent, as Word does. A list's
+ `margin-top` / `margin-bottom` and an item's margins give the space
+ around them.
- Images: `width` / `height` (`%` of the line), `display: block` with
`margin: auto` centres. An `` in a line of text is drawn as a block
of its own: the paragraph splits around it, and it takes the text-align.
diff --git a/docs/ru/USAGE.md b/docs/ru/USAGE.md
index f3a705b..ca882e7 100644
--- a/docs/ru/USAGE.md
+++ b/docs/ru/USAGE.md
@@ -252,6 +252,8 @@ new Section($body); // концевые сноск
С `footnotesAtPageFoot` страница без сносок ничего не теряет, а строка,
чьи сноски не помещаются на её странице, переходит дальше вместе с ними —
так, как размещает их текстовый редактор.
+`new Footnote('текст', new RunStyle(sizePt: 8))` задаёт собственный стиль
+текста сноски.
### Настройка страницы и разрывы секций
@@ -381,6 +383,15 @@ $doc->toFile('report.pdf');
`height`, `colspan`, `rowspan`; строки: `height`,
`break-inside: avoid`. Строки `` повторяются на каждой
странице.
+- Списки: ``, `type` и `list-style-type` (decimal, alpha,
+ roman, `none`), ``; `::marker { content }` со строками,
+ `counter(list-item)` и `counters(list-item, ".")` подписывает пункты —
+ `3.1.` под пунктом 3, как записывается многоуровневая нумерация Word;
+ пункт с `list-style-type: none` без собственного содержимого только несёт
+ вложенный список. `padding-left` / `margin-left` списка и `margin-left`
+ пункта задают, где начинается текст пунктов; метка висит перед ним, а
+ табуляция после неё ставит текст на отступ, как в Word. `margin-top` /
+ `margin-bottom` списка и поля пункта задают отступы вокруг них.
- Картинки: `width` / `height` (`%` от строки), `display: block` с
`margin: auto` центрирует. `` в строке текста рисуется отдельным
блоком: абзац делится вокруг него, а он берёт выравнивание текста.
diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md
index 74a70ca..1b51c37 100644
--- a/docs/zh/USAGE.md
+++ b/docs/zh/USAGE.md
@@ -237,7 +237,7 @@ new Section($body); // 分节结束后的尾注
使用 `footnotesAtPageFoot` 时,没有脚注的页面不会损失空间;而当某行的脚注
在本页放不下时,该行会连同脚注一起移到下一页——与文字处理软件的排版方式
-相同。
+相同。`new Footnote('文本', new RunStyle(sizePt: 8))` 为脚注设置自己的文字样式。
### 页面设置与分节符
@@ -354,6 +354,14 @@ $doc->toFile('report.pdf');
` ` 宽度;单元格:`border`、`background-color`、`padding`、
`vertical-align`、`width`、`height`、`colspan`、`rowspan`;行:
`height`、`break-inside: avoid`。`` 行在每一页重复。
+- 列表:``、`type` 与 `list-style-type`(decimal、alpha、roman、
+ `none`)、``;带字符串、`counter(list-item)` 和
+ `counters(list-item, ".")` 的 `::marker { content }` 为列表项加标签——在第 3 项
+ 下显示 `3.1.`,与 Word 多级编号的写法一致;`list-style-type: none` 且自身无
+ 内容的列表项只承载其嵌套列表。列表的 `padding-left` / `margin-left` 与列表项的
+ `margin-left` 决定列表项文字的起点;标签悬挂在其前方,标签后的制表位把文字
+ 放到缩进处,与 Word 一致。列表的 `margin-top` / `margin-bottom` 与列表项的外边距
+ 决定其周围的间距。
- 图片:`width` / `height`(`%` 相对于行宽),`display: block` 配合
`margin: auto` 居中。文字行中的 `` 作为独立的块绘制:段落在其周围
拆分,它沿用文字的对齐方式。
diff --git a/src/Element/Footnote.php b/src/Element/Footnote.php
index 5b54dc4..5f7732f 100644
--- a/src/Element/Footnote.php
+++ b/src/Element/Footnote.php
@@ -17,5 +17,12 @@
*/
final readonly class Footnote implements InlineElement
{
- public function __construct(public string $content) {}
+ public function __construct(
+ public string $content,
+ /**
+ * How the note's text is set where it is printed — notes are usually
+ * smaller than the body. Null for the engine's default.
+ */
+ public ?\Dskripchenko\PhpPdf\Style\RunStyle $style = null,
+ ) {}
}
diff --git a/src/Element/ListItem.php b/src/Element/ListItem.php
index d4cf84d..09f7031 100644
--- a/src/Element/ListItem.php
+++ b/src/Element/ListItem.php
@@ -18,5 +18,14 @@
public function __construct(
public array $children,
public ?ListNode $nestedList = null,
+ /**
+ * The label as written — `3.1. ` from a `::marker` rule; '' for no
+ * label; null for the list's own format.
+ */
+ public ?string $marker = null,
+ /** The item's number when it does not follow the previous one (``). */
+ public ?int $value = null,
+ /** The item's own extra indent (its CSS margin-left), on top of the list's. */
+ public float $indentPt = 0.0,
) {}
}
diff --git a/src/Element/ListNode.php b/src/Element/ListNode.php
index 530af1a..ae38346 100644
--- a/src/Element/ListNode.php
+++ b/src/Element/ListNode.php
@@ -24,6 +24,12 @@ public function __construct(
public int $startAt = 1,
public float $spaceBeforePt = 0,
public float $spaceAfterPt = 6,
+ /**
+ * How far the items' text sits in from the enclosing content (CSS
+ * padding and margin of the list); null for the usual indent per
+ * level.
+ */
+ public ?float $indentPt = null,
) {}
public function effectiveFormat(): ListFormat
diff --git a/src/Html/Css/Selector.php b/src/Html/Css/Selector.php
index 3c7bf09..828a476 100644
--- a/src/Html/Css/Selector.php
+++ b/src/Html/Css/Selector.php
@@ -38,7 +38,7 @@ public static function parse(string $selector): ?self
{
$selector = trim((string) preg_replace('/\s*([>+~])\s*/', ' $1 ', trim($selector)));
$pseudoElement = null;
- if (preg_match('/::?(before|after)$/i', $selector, $m) === 1) {
+ if (preg_match('/::?(before|after)$/i', $selector, $m) === 1 || preg_match('/::(marker)$/i', $selector, $m) === 1) {
$pseudoElement = strtolower($m[1]);
$selector = rtrim(substr($selector, 0, -strlen($m[0])));
// "::before" alone applies to every element.
diff --git a/src/Html/Css/StyleSheet.php b/src/Html/Css/StyleSheet.php
index fc6769a..0b5e423 100644
--- a/src/Html/Css/StyleSheet.php
+++ b/src/Html/Css/StyleSheet.php
@@ -163,10 +163,10 @@ public function declarationsFor(\DOMElement $element): array
}
/**
- * The declarations of an element's `::before` or `::after`, by the
+ * The declarations of an element's `::before`, `::after` or `::marker`, by the
* cascade; empty when no rule targets it.
*
- * @param 'before'|'after' $which
+ * @param 'before'|'after'|'marker' $which
* @return array
*/
public function pseudoDeclarationsFor(\DOMElement $element, string $which): array
diff --git a/src/Html/HtmlParser.php b/src/Html/HtmlParser.php
index 9141087..f0d6e6f 100644
--- a/src/Html/HtmlParser.php
+++ b/src/Html/HtmlParser.php
@@ -121,6 +121,9 @@ final class HtmlParser
private StyleSheet $css;
+ /** @var list The numbers of the list items being read, outermost first. */
+ private array $listNumbers = [];
+
/**
* @param float $baseFontSizePt The font size `em`, `rem` and `%` refer to
* outside any element that sets one — the
@@ -1839,7 +1842,14 @@ private function attributeWidth(string $value): array
private function parseList(\DOMElement $node, bool $ordered): ListNode
{
+ $listDecl = $this->decl($node);
+ $type = strtolower(trim($listDecl['list-style-type'] ?? ''));
+ $format = self::listFormat($type)
+ ?? ($ordered ? (self::listFormat($node->getAttribute('type')) ?? ListFormat::Decimal) : ListFormat::Bullet);
+ $startAt = $ordered && is_numeric($node->getAttribute('start')) ? (int) $node->getAttribute('start') : 1;
+
$items = [];
+ $number = $startAt - 1;
foreach ($node->childNodes as $child) {
if (! $child instanceof \DOMElement || $this->isHidden($child)) {
continue;
@@ -1847,20 +1857,93 @@ private function parseList(\DOMElement $node, bool $ordered): ListNode
if (strtolower($child->nodeName) !== 'li') {
continue;
}
+ $value = is_numeric($child->getAttribute('value')) ? (int) $child->getAttribute('value') : null;
+ $number = $value ?? $number + 1;
+ $marker = $this->listMarker($child, $number, $type === 'none');
$this->enter($child, $this->currentStyle(), true);
+ // The numbers of the enclosing items, for counters(list-item).
+ $this->listNumbers[] = $number;
try {
- $items[] = $this->parseListItem($child);
+ $item = $this->parseListItem($child);
} finally {
+ array_pop($this->listNumbers);
$this->leave();
}
+ $itemIndent = isset($this->decl($child)['margin-left']) ? $this->sides($this->decl($child), 'margin')[3] : 0.0;
+ $items[] = new ListItem($item->children, $item->nestedList, $marker, $value, $itemIndent);
}
+ // A list that says where its items stand (padding, margin) is placed
+ // there; otherwise by the usual indent per level.
+ $indent = isset($listDecl['padding-left']) || isset($listDecl['margin-left'])
+ ? $this->sides($listDecl, 'padding')[3] + $this->sides($listDecl, 'margin')[3]
+ : null;
+
+ // The space around the list as its CSS says; without a word about
+ // it, the usual gap after a list.
+ [$marginTop, , $marginBottom] = $this->sides($listDecl, 'margin');
+
return new ListNode(
items: $items,
- format: $ordered ? ListFormat::Decimal : ListFormat::Bullet,
+ format: $format,
+ startAt: $startAt,
+ spaceBeforePt: $marginTop,
+ spaceAfterPt: isset($listDecl['margin-bottom']) ? $marginBottom : 6.0,
+ indentPt: $indent,
);
}
+ /** A CSS list-style-type or an HTML `type` as a format; null for others. */
+ private static function listFormat(string $type): ?ListFormat
+ {
+ return match ($type) {
+ 'decimal', '1' => ListFormat::Decimal,
+ 'lower-alpha', 'lower-latin', 'a' => ListFormat::LowerLetter,
+ 'upper-alpha', 'upper-latin', 'A' => ListFormat::UpperLetter,
+ 'lower-roman', 'i' => ListFormat::LowerRoman,
+ 'upper-roman', 'I' => ListFormat::UpperRoman,
+ 'disc', 'circle', 'square' => ListFormat::Bullet,
+ default => null,
+ };
+ }
+
+ /**
+ * An item's label when it is not the list's plain one: '' for
+ * `list-style-type: none`, the text of a `::marker` rule's content —
+ * strings, `counter(list-item)`, `counters(list-item, ".")` — else
+ * null.
+ */
+ private function listMarker(\DOMElement $li, int $number, bool $listHasNone): ?string
+ {
+ $own = strtolower(trim($this->decl($li)['list-style-type'] ?? ''));
+ if ($own === 'none' || ($own === '' && $listHasNone)) {
+ return '';
+ }
+ $content = trim($this->css->pseudoDeclarationsFor($li, 'marker')['content'] ?? '');
+ if ($content === '' || in_array(strtolower($content), ['normal', 'none'], true)) {
+ return strtolower($content) === 'none' ? '' : null;
+ }
+
+ $text = '';
+ $rest = $content;
+ $styleOf = static fn (?string $style): ListFormat => self::listFormat(strtolower(trim((string) $style))) ?? ListFormat::Decimal;
+ while ($rest !== '') {
+ if (preg_match('/^(["\'])((?:(?!\1)[^\\\\]|\\\\.)*)\1\s*/', $rest, $m) === 1) {
+ $text .= stripcslashes($m[2]);
+ } elseif (preg_match('/^counters\(\s*list-item\s*,\s*(["\'])((?:(?!\1)[^\\\\]|\\\\.)*)\1\s*(?:,\s*([\w-]+)\s*)?\)\s*/', $rest, $m) === 1) {
+ $format = $styleOf($m[3] ?? null);
+ $text .= implode(stripcslashes($m[2]), array_map(static fn (int $n): string => $format->number($n), [...$this->listNumbers, $number]));
+ } elseif (preg_match('/^counter\(\s*list-item\s*(?:,\s*([\w-]+)\s*)?\)\s*/', $rest, $m) === 1) {
+ $text .= $styleOf($m[1] ?? null)->number($number);
+ } else {
+ return null; // a content the parser cannot say: the list's own label
+ }
+ $rest = substr($rest, strlen($m[0]));
+ }
+
+ return $text;
+ }
+
private function parseListItem(\DOMElement $node): ListItem
{
// Separate inline content from nested list.
@@ -1897,10 +1980,26 @@ private function parseListItem(\DOMElement $node): ListItem
if ($pendingInlines !== []) {
$this->pushParagraph($blocks, $pendingInlines);
}
- if ($blocks === []) {
+ // An item with only a nested list has no paragraph of its own: an
+ // empty one would print as a blank line.
+ if ($blocks === [] && $nestedList === null) {
$blocks[] = new Paragraph([new Run('')]);
}
+ // The item's own vertical margins are its paragraphs' space — Word's
+ // spacing before and after a numbered paragraph.
+ [$marginTop, , $marginBottom] = $this->sides($this->decl($node), 'margin');
+ $last = count($blocks) - 1;
+ foreach ($blocks as $i => $block) {
+ if ($block instanceof Paragraph && (($i === 0 && $marginTop > 0) || ($i === $last && $marginBottom > 0))) {
+ $blocks[$i] = new Paragraph($block->children, $this->styleCopy(
+ $block->style,
+ spaceBefore: $i === 0 && $marginTop > 0 ? $marginTop : null,
+ spaceAfter: $i === $last && $marginBottom > 0 ? $marginBottom : null,
+ ), $block->headingLevel, $block->defaultRunStyle);
+ }
+ }
+
return new ListItem(children: $blocks, nestedList: $nestedList);
}
diff --git a/src/Layout/Engine.php b/src/Layout/Engine.php
index 53ad222..29f128c 100644
--- a/src/Layout/Engine.php
+++ b/src/Layout/Engine.php
@@ -109,6 +109,9 @@ final class Engine
/** @var array footnote number → its text */
private array $footnoteTexts = [];
+ /** @var array The notes' own text styles, by number. */
+ private array $footnoteStyles = [];
+
/** @var array> page spl_object_id → numbers placed on it */
private array $pageFootnotes = [];
@@ -267,6 +270,7 @@ private function assignFootnoteNumbers(AstDocument $document): void
{
$this->footnoteNumbers = [];
$this->footnoteTexts = [];
+ $this->footnoteStyles = [];
$this->pageFootnotes = [];
$this->pageFootnoteRoom = [];
@@ -285,6 +289,7 @@ private function collectFootnotes(iterable $nodes): void
$number = count($this->footnoteNumbers) + 1;
$this->footnoteNumbers[$id] = $number;
$this->footnoteTexts[$number] = $node->content;
+ $this->footnoteStyles[$number] = $node->style;
}
continue;
@@ -309,6 +314,7 @@ private function footnoteNumberFor(\Dskripchenko\PhpPdf\Element\Footnote $footno
$number = count($this->footnoteNumbers) + 1;
$this->footnoteNumbers[$id] = $number;
$this->footnoteTexts[$number] = $footnote->content;
+ $this->footnoteStyles[$number] = $footnote->style;
}
return $this->footnoteNumbers[$id];
@@ -332,7 +338,7 @@ private function registerFootnoteOnPage(Page $page, int $number): void
/** A note as it is drawn at the foot of the page. */
private function footnoteParagraph(int $number): Paragraph
{
- return new Paragraph([new Run($number.'. '.($this->footnoteTexts[$number] ?? ''))]);
+ return new Paragraph([new Run($number.'. '.($this->footnoteTexts[$number] ?? ''), $this->footnoteStyles[$number] ?? new RunStyle)]);
}
/**
@@ -541,7 +547,7 @@ private function renderBlock(BlockElement $block, LayoutContext $ctx): void
$block instanceof HorizontalRule => $this->renderHorizontalRule($ctx),
$block instanceof Image => $this->renderImage($block, $ctx),
$block instanceof Table => $this->renderTable($block, $ctx),
- $block instanceof ListNode => $this->renderListNode($block, $ctx, 0),
+ $block instanceof ListNode => $this->renderListNode($block, $ctx, 0, 0.0),
$block instanceof Barcode => $this->renderBarcode($block, $ctx),
$block instanceof ColumnSet => $this->renderColumnSet($block, $ctx),
$block instanceof FormField => $this->renderFormField($block, $ctx),
@@ -3144,9 +3150,8 @@ private function renderEndnotes(LayoutContext $ctx): void
);
$ctx->cursorY -= 8.0;
- foreach ($this->footnoteTexts as $number => $content) {
- $marker = $number.'. ';
- $p = new Paragraph([new Run($marker.$content)]);
+ foreach (array_keys($this->footnoteTexts) as $number) {
+ $p = $this->footnoteParagraph($number);
$this->renderBlock($p, $ctx);
}
}
@@ -3954,6 +3959,14 @@ private function emitLine(
// Tab item — advance x to next tab stop.
if (($item['type'] ?? null) === 'tab') {
$flushBatch();
+ // A hanging indent is a tab stop of its own on the first line,
+ // as in Word: a list label and a tab put the text on the indent.
+ $hangingStop = $ctx->leftX + $p->style->indentLeftPt;
+ if ($isFirstLine && $p->style->indentFirstLinePt < 0 && $x < $hangingStop - 0.01) {
+ $x = $hangingStop;
+
+ continue;
+ }
$relativeX = $x - $startX;
$nextStop = ((int) ($relativeX / $this->tabStopPt) + 1) * $this->tabStopPt;
$newX = $startX + $nextStop;
@@ -5169,6 +5182,15 @@ private function splitRowForPage(Table $t, Row $row, array $colWidths, float $av
continue;
}
+ // A list splits between its items: a long list in a cell
+ // would otherwise carry its whole row to the next page.
+ if ($tail === [] && $block instanceof ListNode && ($parts = $this->splitListForHeight($block, $room - $used, $contentWidth)) !== null) {
+ $head[] = $parts[0];
+ $tail[] = $parts[1];
+ $used = $room;
+
+ continue;
+ }
$tail[] = $block;
}
@@ -5200,6 +5222,59 @@ private function splitRowForPage(Table $t, Row $row, array $colWidths, float $av
return [new Row($headCells, false), new Row($tailCells, false)];
}
+ /**
+ * A list cut to a height: the items that fit, and the rest numbering on
+ * from them. An item whose nested list does not fit is cut inside that
+ * list, its rest going on under a label-less item of the same number.
+ * Null when not even the first item's own text fits.
+ *
+ * @return array{0: ListNode, 1: ListNode}|null
+ */
+ private function splitListForHeight(ListNode $list, float $room, float $contentWidth, int $level = 0): ?array
+ {
+ $used = $list->spaceBeforePt;
+ $number = $list->startAt - 1;
+ $head = [];
+ foreach ($list->items as $i => $item) {
+ $number = $item->value ?? $number + 1;
+ $single = static fn (ListItem $it, int $n): ListNode => new ListNode([$it], $list->format, $n, 0, 0, $list->indentPt);
+ $height = $this->measureListNodeHeight($single($item, $number), $contentWidth, $level);
+ if ($used + $height <= $room) {
+ $head[] = $item;
+ $used += $height;
+
+ continue;
+ }
+
+ $rest = array_slice($list->items, $i + 1);
+ $tailFirst = null;
+ if ($item->nestedList !== null) {
+ $own = $this->measureListNodeHeight($single(new ListItem($item->children, null, $item->marker, null, $item->indentPt), $number), $contentWidth, $level);
+ $indent = ($list->indentPt ?? self::LIST_LEVEL_INDENT_PT) + $item->indentPt;
+ $nested = $used + $own < $room
+ ? $this->splitListForHeight($item->nestedList, $room - $used - $own, $contentWidth - $indent, $level + 1)
+ : null;
+ if ($nested !== null) {
+ $head[] = new ListItem($item->children, $nested[0], $item->marker, $item->value, $item->indentPt);
+ $tailFirst = new ListItem([], $nested[1], '', $number, $item->indentPt);
+ }
+ }
+ if ($tailFirst === null) {
+ if ($head === []) {
+ return null;
+ }
+ $tailFirst = new ListItem($item->children, $item->nestedList, $item->marker, $number, $item->indentPt);
+ }
+
+ return [
+ new ListNode($head, $list->format, $list->startAt, $list->spaceBeforePt, 0, $list->indentPt),
+ new ListNode([$tailFirst, ...$rest], $list->format, $number, 0, $list->spaceAfterPt, $list->indentPt),
+ ];
+ }
+
+ return null;
+ }
+
/**
* @param list $colWidths
* @param array $prevRowBottomByCol
@@ -5755,15 +5830,14 @@ private function measureListNodeHeight(ListNode $list, float $contentWidth, int
return 0;
}
$total = $list->spaceBeforePt;
- $baseIndent = ($level + 1) * self::LIST_LEVEL_INDENT_PT;
- $itemContentWidth = $contentWidth - $baseIndent;
foreach ($list->items as $item) {
+ $baseIndent = ($list->indentPt ?? self::LIST_LEVEL_INDENT_PT) + $item->indentPt;
foreach ($item->children as $child) {
- $total += $this->measureBlockHeight($child, $itemContentWidth);
+ $total += $this->measureBlockHeight($child, $contentWidth - $baseIndent);
}
if ($item->nestedList !== null) {
- $total += $this->measureListNodeHeight($item->nestedList, $contentWidth, $level + 1);
+ $total += $this->measureListNodeHeight($item->nestedList, $contentWidth - $baseIndent, $level + 1);
}
}
$total += $list->spaceAfterPt;
@@ -5799,7 +5873,10 @@ private function measureTableHeight(Table $t, float $contentWidth): float
* Nested ListNode (via ListItem.nestedList) is rendered recursively
* with an increased $level.
*/
- private function renderListNode(ListNode $list, LayoutContext $ctx, int $level): void
+ /**
+ * @param float $parentIndent Where the enclosing item's text starts, from the content edge.
+ */
+ private function renderListNode(ListNode $list, LayoutContext $ctx, int $level, float $parentIndent): void
{
if ($list->isEmpty()) {
return;
@@ -5813,11 +5890,24 @@ private function renderListNode(ListNode $list, LayoutContext $ctx, int $level):
}
$format = $list->effectiveFormat();
- $baseIndent = ($level + 1) * self::LIST_LEVEL_INDENT_PT;
- foreach ($list->items as $i => $item) {
- $number = $list->startAt + $i;
- $marker = $this->formatListMarker($number, $format);
+ $number = $list->startAt - 1;
+ foreach ($list->items as $item) {
+ $number = $item->value ?? $number + 1;
+ $marker = $item->marker ?? $this->formatListMarker($number, $format);
+ // The list's indent (the usual one per level, or its CSS) plus
+ // the item's own, from where the enclosing item's text starts.
+ $baseIndent = $parentIndent + ($list->indentPt ?? self::LIST_LEVEL_INDENT_PT) + $item->indentPt;
+
+ // An item with no label and no content only carries its nested
+ // list (it stands for a parent outside the list).
+ if ($marker === '' && $item->children === []) {
+ if ($item->nestedList !== null) {
+ $this->renderListNode($item->nestedList, $ctx, $level + 1, $baseIndent);
+ }
+
+ continue;
+ }
// Per-item /LI wrap.
$itemMcid = null;
@@ -5864,10 +5954,13 @@ private function renderListItem(
if ($firstChild instanceof Paragraph) {
// Prepend marker to first paragraph's first child by injecting
// a new Run. Hanging indent via indentLeftPt+indentFirstLinePt.
- $newFirstChildren = [new Run($marker), ...$firstChild->children];
+ // The label and a tab: the hanging indent stops the tab, and the
+ // text starts on the indent whatever the label's width.
+ $newFirstChildren = [new Run($marker === '' ? '' : rtrim($marker)."\t"), ...$firstChild->children];
+ // The label hangs in front of the text, never past the content edge.
$newStyle = $firstChild->style->copy(
indentLeftPt: $baseIndent,
- indentFirstLinePt: -self::LIST_LEVEL_INDENT_PT,
+ indentFirstLinePt: -min(self::LIST_LEVEL_INDENT_PT, $baseIndent),
);
$modified = new Paragraph(
children: $newFirstChildren,
@@ -5898,18 +5991,20 @@ private function renderListItem(
} else {
// First child is not a paragraph. Render marker as a standalone
// paragraph + then all children with indent.
- $markerP = new Paragraph(
- children: [new Run(trim($marker))],
- style: new ParagraphStyle(indentLeftPt: $baseIndent - self::LIST_LEVEL_INDENT_PT),
- );
- $this->renderBlock($markerP, $ctx);
+ if ($marker !== '') {
+ $markerP = new Paragraph(
+ children: [new Run(trim($marker))],
+ style: new ParagraphStyle(indentLeftPt: max(0.0, $baseIndent - self::LIST_LEVEL_INDENT_PT)),
+ );
+ $this->renderBlock($markerP, $ctx);
+ }
foreach ($children as $child) {
$this->renderIndentedBlock($child, $baseIndent, $ctx);
}
}
if ($item->nestedList !== null) {
- $this->renderListNode($item->nestedList, $ctx, $level + 1);
+ $this->renderListNode($item->nestedList, $ctx, $level + 1, $baseIndent);
}
}
@@ -5930,51 +6025,7 @@ private function renderIndentedBlock(BlockElement $block, float $indentPt, Layou
private function formatListMarker(int $n, ListFormat $f): string
{
- return match ($f) {
- ListFormat::Bullet => "\u{2022} ", // •
- ListFormat::Decimal => $n.'. ',
- ListFormat::LowerLetter => $this->toLetter($n, false).'. ',
- ListFormat::UpperLetter => $this->toLetter($n, true).'. ',
- ListFormat::LowerRoman => strtolower($this->toRoman($n)).'. ',
- ListFormat::UpperRoman => $this->toRoman($n).'. ',
- };
- }
-
- private function toLetter(int $n, bool $upper): string
- {
- if ($n < 1) {
- return '';
- }
- $base = $upper ? 65 : 97;
- $result = '';
- while ($n > 0) {
- $n--;
- $result = chr($base + ($n % 26)).$result;
- $n = (int) ($n / 26);
- }
-
- return $result;
- }
-
- private function toRoman(int $n): string
- {
- if ($n < 1) {
- return '';
- }
- $map = [
- ['M', 1000], ['CM', 900], ['D', 500], ['CD', 400],
- ['C', 100], ['XC', 90], ['L', 50], ['XL', 40],
- ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1],
- ];
- $result = '';
- foreach ($map as [$sym, $val]) {
- while ($n >= $val) {
- $result .= $sym;
- $n -= $val;
- }
- }
-
- return $result;
+ return $f === ListFormat::Bullet ? "\u{2022} " : $f->number($n).'. ';
}
/**
diff --git a/src/Style/ListFormat.php b/src/Style/ListFormat.php
index bef4c32..e8b9ca2 100644
--- a/src/Style/ListFormat.php
+++ b/src/Style/ListFormat.php
@@ -20,4 +20,42 @@ public function isOrdered(): bool
{
return $this !== self::Bullet;
}
+
+ /** A number in this format: `3`, `c`, `C`, `iii`, `III`; a bullet for Bullet. */
+ public function number(int $n): string
+ {
+ return match ($this) {
+ self::Bullet => "\u{2022}",
+ self::Decimal => (string) $n,
+ self::LowerLetter => strtolower(self::letters($n)),
+ self::UpperLetter => self::letters($n),
+ self::LowerRoman => strtolower(self::roman($n)),
+ self::UpperRoman => self::roman($n),
+ };
+ }
+
+ private static function letters(int $n): string
+ {
+ $result = '';
+ while ($n > 0) {
+ $n--;
+ $result = chr(65 + ($n % 26)).$result;
+ $n = intdiv($n, 26);
+ }
+
+ return $result;
+ }
+
+ private static function roman(int $n): string
+ {
+ $result = '';
+ foreach (['M' => 1000, 'CM' => 900, 'D' => 500, 'CD' => 400, 'C' => 100, 'XC' => 90, 'L' => 50, 'XL' => 40, 'X' => 10, 'IX' => 9, 'V' => 5, 'IV' => 4, 'I' => 1] as $symbol => $value) {
+ while ($n >= $value) {
+ $result .= $symbol;
+ $n -= $value;
+ }
+ }
+
+ return $result;
+ }
}
diff --git a/tests/Html/HtmlListMarkersTest.php b/tests/Html/HtmlListMarkersTest.php
new file mode 100644
index 0000000..63fc39d
--- /dev/null
+++ b/tests/Html/HtmlListMarkersTest.php
@@ -0,0 +1,94 @@
+`, `list-style-type: none`, and `::marker` labels with the
+ * `list-item` counter — the way Word's multilevel numbering (`3.1.`)
+ * arrives from an import.
+ */
+final class HtmlListMarkersTest extends TestCase
+{
+ private static function text(string $html): string
+ {
+ $words = PdfGeometry::words(Document::fromHtml($html)->toBytes(new Engine(compressStreams: false)))
+ ?? self::markTestSkipped('pdftotext is not available');
+
+ // Reading order by line, then left to right: a label and its text
+ // sit on one line with a gap pdftotext may read as two columns.
+ usort($words, static fn (array $a, array $b): int => [$a['page'], round($a['yMin']), $a['xMin']] <=> [$b['page'], round($b['yMin']), $b['xMin']]);
+
+ return implode(' ', array_column($words, 'text'));
+ }
+
+ #[Test]
+ public function start_type_and_value_number_the_items(): void
+ {
+ $list = (new HtmlParser)->parse('a b c ')[0];
+
+ self::assertInstanceOf(ListNode::class, $list);
+ self::assertSame(ListFormat::UpperLetter, $list->format);
+ self::assertSame(3, $list->startAt);
+ self::assertSame('C. a G. b H. c', self::text('a b c '));
+ self::assertSame('ii. x', self::text('x '));
+ }
+
+ #[Test]
+ public function marker_rules_label_the_items_with_the_list_item_counter(): void
+ {
+ $html = ''
+ .'first second '
+ .'prefixed ';
+
+ self::assertSame('3.1. first 3.2. second 4.1. prefixed', self::text($html));
+ }
+
+ #[Test]
+ public function an_item_without_a_label_and_content_draws_only_its_nested_list(): void
+ {
+ $list = (new HtmlParser)->parse('inner ')[0];
+
+ self::assertInstanceOf(ListNode::class, $list);
+ self::assertSame('', $list->items[0]->marker);
+ self::assertSame('1. inner', self::text('inner '));
+ }
+
+ #[Test]
+ public function list_format_numbers(): void
+ {
+ self::assertSame('xiv', ListFormat::LowerRoman->number(14));
+ self::assertSame('AB', ListFormat::UpperLetter->number(28));
+ self::assertSame('c', ListFormat::LowerLetter->number(3));
+ }
+
+ #[Test]
+ public function a_list_that_says_its_indent_is_placed_there(): void
+ {
+ $default = (new HtmlParser)->parse('x ')[0];
+ $placed = (new HtmlParser)->parse('x ')[0];
+
+ self::assertInstanceOf(ListNode::class, $default);
+ self::assertInstanceOf(ListNode::class, $placed);
+ self::assertNull($default->indentPt);
+ self::assertSame(0.0, $placed->indentPt);
+ self::assertSame(30.0, $placed->items[0]->indentPt);
+
+ $pdf = Document::fromHtml('Edge
Placed ')->toBytes(new Engine(compressStreams: false));
+ $edge = PdfGeometry::word($pdf, 'Edge');
+ $text = PdfGeometry::word($pdf, 'Placed');
+ // The label hangs in front; the text starts past the 60pt indent.
+ self::assertGreaterThan($edge['xMin'] + 55, $text['xMin']);
+ }
+}
diff --git a/tests/Layout/FootnotesAtPageFootTest.php b/tests/Layout/FootnotesAtPageFootTest.php
index 6a5072e..e8a3c31 100644
--- a/tests/Layout/FootnotesAtPageFootTest.php
+++ b/tests/Layout/FootnotesAtPageFootTest.php
@@ -93,4 +93,14 @@ public function a_note_in_a_table_row_takes_room_on_the_rows_page(): void
self::assertSame($noted['page'], $cell['page']);
self::assertGreaterThan($noted['yMax'], $cell['yMin']); // the note below the row, not over it
}
+
+ #[Test]
+ public function a_note_is_set_in_its_own_style(): void
+ {
+ $pdf = self::render([new Paragraph([new Run('Body'), new Footnote('Small note', new \Dskripchenko\PhpPdf\Style\RunStyle(sizePt: 7))])]);
+
+ $body = PdfGeometry::word($pdf, 'Body');
+ $note = PdfGeometry::word($pdf, 'Small');
+ self::assertLessThan($body['yMax'] - $body['yMin'], $note['yMax'] - $note['yMin']);
+ }
}