Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<ol start>`, `type` and `list-style-type` (decimal, alpha,
roman, `none`), `<li value>`, 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: `<ol start>` 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
Expand Down
13 changes: 13 additions & 0 deletions docs/de/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -382,6 +384,17 @@ $doc->toFile('report.pdf');
`background-color`, `padding`, `vertical-align`, `width`, `height`,
`colspan`, `rowspan`; Zeilen: `height`, `break-inside: avoid`.
`<thead>`-Zeilen wiederholen sich auf jeder Seite.
- Listen: `<ol start>`, `type` und `list-style-type` (decimal, alpha,
roman, `none`), `<li value>`; `::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 `<svg>` in einer Textzeile wird als eigener
Block gezeichnet: der Absatz wird um ihn geteilt, und er übernimmt die
Expand Down
13 changes: 12 additions & 1 deletion docs/en/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -369,6 +370,16 @@ $doc->toFile('report.pdf');
`padding`, `vertical-align`, `width`, `height`, `colspan`, `rowspan`;
rows: `height`, `break-inside: avoid`. `<thead>` rows repeat on every
page.
- Lists: `<ol start>`, `type` and `list-style-type` (decimal, alpha,
roman, `none`), `<li value>`; `::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 `<svg>` in a line of text is drawn as a block
of its own: the paragraph splits around it, and it takes the text-align.
Expand Down
11 changes: 11 additions & 0 deletions docs/ru/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ new Section($body); // концевые сноск
С `footnotesAtPageFoot` страница без сносок ничего не теряет, а строка,
чьи сноски не помещаются на её странице, переходит дальше вместе с ними —
так, как размещает их текстовый редактор.
`new Footnote('текст', new RunStyle(sizePt: 8))` задаёт собственный стиль
текста сноски.

### Настройка страницы и разрывы секций

Expand Down Expand Up @@ -381,6 +383,15 @@ $doc->toFile('report.pdf');
`height`, `colspan`, `rowspan`; строки: `height`,
`break-inside: avoid`. Строки `<thead>` повторяются на каждой
странице.
- Списки: `<ol start>`, `type` и `list-style-type` (decimal, alpha,
roman, `none`), `<li value>`; `::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` центрирует. `<svg>` в строке текста рисуется отдельным
блоком: абзац делится вокруг него, а он берёт выравнивание текста.
Expand Down
10 changes: 9 additions & 1 deletion docs/zh/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ new Section($body); // 分节结束后的尾注

使用 `footnotesAtPageFoot` 时,没有脚注的页面不会损失空间;而当某行的脚注
在本页放不下时,该行会连同脚注一起移到下一页——与文字处理软件的排版方式
相同。
相同。`new Footnote('文本', new RunStyle(sizePt: 8))` 为脚注设置自己的文字样式。

### 页面设置与分节符

Expand Down Expand Up @@ -354,6 +354,14 @@ $doc->toFile('report.pdf');
`<col>` 宽度;单元格:`border`、`background-color`、`padding`、
`vertical-align`、`width`、`height`、`colspan`、`rowspan`;行:
`height`、`break-inside: avoid`。`<thead>` 行在每一页重复。
- 列表:`<ol start>`、`type` 与 `list-style-type`(decimal、alpha、roman、
`none`)、`<li value>`;带字符串、`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` 居中。文字行中的 `<svg>` 作为独立的块绘制:段落在其周围
拆分,它沿用文字的对齐方式。
Expand Down
9 changes: 8 additions & 1 deletion src/Element/Footnote.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}
}
9 changes: 9 additions & 0 deletions src/Element/ListItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<li value>`). */
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,
) {}
}
6 changes: 6 additions & 0 deletions src/Element/ListNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Html/Css/Selector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/Html/Css/StyleSheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
*/
public function pseudoDeclarationsFor(\DOMElement $element, string $which): array
Expand Down
105 changes: 102 additions & 3 deletions src/Html/HtmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ final class HtmlParser

private StyleSheet $css;

/** @var list<int> 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
Expand Down Expand Up @@ -1839,28 +1842,108 @@ 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;
}
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.
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading