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`), `
  1. `, 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`), `
      1. `; `::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`), `
        1. `; `::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`), `
          1. `; `::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`)、`
            1. `;带字符串、`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 (`
            2. `). */ + 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('
              1. a
              2. b
              3. 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('
              1. a
              2. b
              3. c
              ')); + self::assertSame('ii. x', self::text('
              1. x
              ')); + } + + #[Test] + public function marker_rules_label_the_items_with_the_list_item_counter(): void + { + $html = '' + .'
                1. first
                2. second
              ' + .'
              1. 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('
                1. inner
              ')[0]; + + self::assertInstanceOf(ListNode::class, $list); + self::assertSame('', $list->items[0]->marker); + self::assertSame('1. inner', self::text('
                1. 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('
              1. x
              ')[0]; + $placed = (new HtmlParser)->parse('
              1. 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

              1. 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']); + } }