diff --git a/CHANGELOG.md b/CHANGELOG.md index 63084bb..c1af09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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] + +### Fixed +- SVG `` was always set in base-14 Helvetica, so Cyrillic and any + other non-Latin text printed as `?` — a stamp "ОПЛАЧЕНО" came out as + "????????". It is set in the document's fonts now: the family chain, the + fallbacks and the bold/italic faces a paragraph uses. +- SVG `` ignored `text-anchor` (centred labels started at their + anchor), a group's `transform` (rotated stamps printed level) and the font + properties of its group or `style`. `font-family`, `font-weight`, + `font-style`, `font-size`, `fill` and `text-anchor` are inherited as SVG + defines, and the glyphs are placed by a text matrix that follows the + group's rotation, scale and skew. + +### Added +- `Page::showTextWithMatrix()` — text placed by a full text matrix, in a + base-14 or an embedded font; `SvgRenderer::render()` takes an optional + font resolver. + ## [1.24.1] — 2026-10-19 ### Fixed diff --git a/docs/de/USAGE.md b/docs/de/USAGE.md index bf66138..e99fc35 100644 --- a/docs/de/USAGE.md +++ b/docs/de/USAGE.md @@ -660,6 +660,16 @@ Bézierkurven), Formen (``, ``, ``, ``, Transformationen (`translate`, `scale`, `rotate`, `skewX`, `skewY`, `matrix`), `` / ``, Styling über CSS-Klassen. +`` wird in den Schriften des Dokuments gesetzt — dieselbe +Familienkette, dieselben Ersatzschriften und Fett-/Kursivschnitte wie ein +Absatz —, kyrillischer und anderer nicht-lateinischer Text erscheint also wie +im Fließtext; ohne eingebettete Schrift fällt er auf eine Base-14-Schrift +zurück, die nur lateinische Glyphen hat. `font-family`, `font-weight`, +`font-style`, `font-size`, `fill` und `text-anchor` werden vom Element, seinem +`style` oder dem nächsten Vorfahren gelesen; das `transform` einer Gruppe +dreht und skaliert die Glyphen mit den Formen. Nicht unterstützt: +Positionierung über ``, ``, `dx`/`dy`. + Inline-SVG funktioniert auch innerhalb von HTML — ``-Tags werden an den SVG-Renderer durchgereicht. diff --git a/docs/en/USAGE.md b/docs/en/USAGE.md index e517ac2..da840b6 100644 --- a/docs/en/USAGE.md +++ b/docs/en/USAGE.md @@ -637,6 +637,15 @@ shapes (``, ``, ``, ``, ``, (`translate`, `scale`, `rotate`, `skewX`, `skewY`, `matrix`), `` / ``, CSS class styling. +`` is set in the document's fonts — the same family chain, fallbacks +and bold/italic faces as a paragraph — so Cyrillic and other non-Latin text +prints as it does in the body; without an embedded font it falls back to a +base-14 face, which has Latin glyphs only. `font-family`, `font-weight`, +`font-style`, `font-size`, `fill` and `text-anchor` are read from the +element, its `style` or the nearest ancestor; a group's `transform` turns and +scales the glyphs with the shapes. Not supported: `` positioning, +``, `dx`/`dy`. + Inline SVG works inside HTML too — `` tags pass through to the SVG renderer. diff --git a/docs/ru/USAGE.md b/docs/ru/USAGE.md index ca882e7..3c506c6 100644 --- a/docs/ru/USAGE.md +++ b/docs/ru/USAGE.md @@ -655,6 +655,15 @@ DocumentBuilder::new() `skewX`, `skewY`, `matrix`), `` / ``, стилизация через CSS-классы. +`` набирается шрифтами документа — той же цепочкой семейств, +запасными шрифтами и начертаниями, что и абзац, — поэтому кириллица и другие +нелатинские письменности печатаются как в тексте; без встроенного шрифта +текст уходит в шрифт base-14, в котором есть только латиница. `font-family`, +`font-weight`, `font-style`, `font-size`, `fill` и `text-anchor` берутся у +элемента, из его `style` или у ближайшего предка; `transform` группы +поворачивает и масштабирует глифы вместе с фигурами. Не поддерживаются: +позиционирование ``, ``, `dx`/`dy`. + Инлайн-SVG работает и внутри HTML — теги `` пробрасываются в SVG-рендерер. diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 1b51c37..49aa8d2 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -611,6 +611,13 @@ DocumentBuilder::new() (`translate`、`scale`、`rotate`、`skewX`、`skewY`、`matrix`)、 `` / ``、CSS 类样式。 +`` 使用文档的字体排版 —— 与段落相同的字体族链、后备字体以及粗体/斜体 +字形 —— 因此西里尔字母及其他非拉丁文字的显示与正文一致;没有嵌入字体时, +会退回到只含拉丁字形的 base-14 字体。`font-family`、`font-weight`、 +`font-style`、`font-size`、`fill` 与 `text-anchor` 从元素本身、其 `style` +或最近的祖先元素读取;组的 `transform` 会让字形随图形一起旋转和缩放。 +不支持:`` 定位、``、`dx`/`dy`。 + 行内 SVG 在 HTML 中同样有效 —— `` 标签会直接交给 SVG 渲染器处理。 diff --git a/src/Layout/Engine.php b/src/Layout/Engine.php index 7851bb3..c67f089 100644 --- a/src/Layout/Engine.php +++ b/src/Layout/Engine.php @@ -1111,7 +1111,16 @@ private function renderSvgElement(SvgElement $svg, LayoutContext $ctx): void }; $blockY = $ctx->cursorY - $h; - \Dskripchenko\PhpPdf\Svg\SvgRenderer::render($svg->svgXml, $ctx->currentPage, $blockX, $blockY, $w, $h); + // SVG text takes the document's fonts — the family chain and the + // fallbacks — like any other run: a base-14 face has no Cyrillic. + \Dskripchenko\PhpPdf\Svg\SvgRenderer::render( + $svg->svgXml, $ctx->currentPage, $blockX, $blockY, $w, $h, + function (?string $family, bool $bold, bool $italic, string $text): PdfFont|StandardFont { + $style = new RunStyle(fontFamily: $family, bold: $bold, italic: $italic); + + return $this->resolveEmbeddedFont($style, $text) ?? $this->standardFontFor($style); + }, + ); $ctx->cursorY -= $h; $ctx->cursorY -= $svg->spaceAfterPt; diff --git a/src/Pdf/ContentStream.php b/src/Pdf/ContentStream.php index fca22ad..5584af7 100644 --- a/src/Pdf/ContentStream.php +++ b/src/Pdf/ContentStream.php @@ -131,6 +131,38 @@ public function textTjArray( return $this; } + /** + * Text placed by a full text matrix — rotated, skewed or scaled glyphs, + * as an SVG `transform` asks for. `$matrix` is `[a, b, c, d, e, f]` of + * `Tm`; `$shown` is the ready showing operand and operator: `(…) Tj`, + * `<…> Tj` or `[…] TJ`. + * + * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $matrix + */ + public function textWithMatrix( + string $fontName, float $sizePt, array $matrix, string $shown, + ?float $r = null, ?float $g = null, ?float $b = null, + ): self { + $this->openTextColor($r, $g, $b); + $this->body .= 'BT'."\n"; + $this->body .= sprintf("/%s %s Tf\n", $fontName, $this->formatNumber($sizePt)); + $this->applyCharSpacing(0.0); + $this->body .= implode(' ', array_map($this->formatNumber(...), $matrix))." Tm\n"; + $this->body .= $shown."\n"; + $this->body .= 'ET'."\n"; + $this->closeTextColor($r); + + return $this; + } + + /** + * A string operand of `Tj`, escaped. + */ + public function literalString(string $text): string + { + return '('.$this->escapeString($text).')'; + } + /** * Writes `Tc` only when the spacing changed — including a return to zero. */ diff --git a/src/Pdf/Page.php b/src/Pdf/Page.php index 1c34053..7623438 100644 --- a/src/Pdf/Page.php +++ b/src/Pdf/Page.php @@ -361,6 +361,33 @@ public function showText( return $this; } + /** + * Text placed by a text matrix `[a, b, c, d, e, f]`: the glyphs are + * rotated, skewed or scaled with it — an SVG `` under a + * `transform`. A base-14 font shows WinAnsi only; an embedded one shows + * any script it has glyphs for, with kerning. + * + * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $matrix + */ + public function showTextWithMatrix( + string $text, array $matrix, StandardFont|PdfFont $font, float $sizePt, + ?float $r = null, ?float $g = null, ?float $b = null, + ): self { + if ($font instanceof PdfFont) { + $resourceName = $this->registerEmbeddedFont($font); + $tjOps = $font->encodeTextTjArray($text); + $shown = count($tjOps) === 1 + ? $tjOps[0].' Tj' + : '['.implode(' ', array_map(static fn (string|int $op): string => (string) $op, $tjOps)).'] TJ'; + } else { + $resourceName = $this->registerStandardFont($font); + $shown = $this->stream->literalString(self::standardFontBytes($font, $text)).' Tj'; + } + $this->stream->textWithMatrix($resourceName, $sizePt, $matrix, $shown, $r, $g, $b); + + return $this; + } + /** * Vertical text (CJK/East-Asian writing mode). * diff --git a/src/Svg/SvgRenderer.php b/src/Svg/SvgRenderer.php index f762582..ddac654 100644 --- a/src/Svg/SvgRenderer.php +++ b/src/Svg/SvgRenderer.php @@ -4,7 +4,12 @@ namespace Dskripchenko\PhpPdf\Svg; +use Dskripchenko\PhpPdf\Layout\TextMeasurer; +use Dskripchenko\PhpPdf\Pdf\Forms\Appearance\StandardFontMetrics; +use Dskripchenko\PhpPdf\Pdf\Forms\Appearance\WinAnsi; use Dskripchenko\PhpPdf\Pdf\Page; +use Dskripchenko\PhpPdf\Pdf\PdfFont; +use Dskripchenko\PhpPdf\Pdf\StandardFont; /** * SVG → PDF native paths renderer. @@ -180,7 +185,33 @@ private static function applyDeclarations(\SimpleXMLElement $el, array $declarat } } - public static function render(string $svgXml, Page $page, float $boxX, float $boxY, float $boxW, float $boxH): void + /** + * The font for an SVG ``: (font-family, bold, italic, the text) → + * a font that has the text's glyphs, or null for the base-14 fallback. + * + * @var (\Closure(?string, bool, bool, string): (StandardFont|PdfFont|null))|null + */ + private static ?\Closure $fontResolver = null; + + /** + * `$fontResolver` picks the font of a ``. Without one — or when it + * gives null — the text is shown in a base-14 face, which has Latin + * glyphs only. + * + * @param (\Closure(?string, bool, bool, string): (StandardFont|PdfFont|null))|null $fontResolver + */ + public static function render(string $svgXml, Page $page, float $boxX, float $boxY, float $boxW, float $boxH, ?\Closure $fontResolver = null): void + { + $previous = self::$fontResolver; + self::$fontResolver = $fontResolver; + try { + self::renderDocument($svgXml, $page, $boxX, $boxY, $boxW, $boxH); + } finally { + self::$fontResolver = $previous; + } + } + + private static function renderDocument(string $svgXml, Page $page, float $boxX, float $boxY, float $boxW, float $boxH): void { // SimpleXML's xpath() cannot address the default namespace without // a registered prefix, which silently disabled every //style, @@ -821,57 +852,116 @@ private static function renderPathXY(\SimpleXMLElement $el, Page $page, callable */ private static function renderTextXY(\SimpleXMLElement $el, Page $page, callable $px, callable $py, float $scaleX): void { - $x = (float) ($el['x'] ?? 0); - $y = (float) ($el['y'] ?? 0); - $fontSize = (float) ($el['font-size'] ?? 16); - [$fr, $fg, $fb, $hasFill] = self::parseColor(isset($el['fill']) ? (string) $el['fill'] : '#000'); - if (! $hasFill) { - return; - } - $text = trim((string) $el); - if ($text === '') { - return; - } - $page->showText( - $text, $px($x, $y), $py($x, $y), - \Dskripchenko\PhpPdf\Pdf\StandardFont::Helvetica, - $fontSize * $scaleX, - $fr, $fg, $fb, - ); + self::drawText($el, $page, static fn (float $x, float $y): array => [$px($x, $y), $py($x, $y)]); } /** - * SVG . Basic support: x, y, font-size, fill. - * - * Limitations: - * - Uses StandardFont::Helvetica (no font-family resolution). - * - text-anchor: middle/end ignored (default = start). - * - tspan / nested elements skipped. - * * @param callable(float): float $tx * @param callable(float): float $ty */ private static function renderText(\SimpleXMLElement $el, Page $page, callable $tx, callable $ty, float $scaleX): void { - $x = (float) ($el['x'] ?? 0); - $y = (float) ($el['y'] ?? 0); - $fontSize = (float) ($el['font-size'] ?? 16); - [$fr, $fg, $fb, $hasFill] = self::parseColor(isset($el['fill']) ? (string) $el['fill'] : '#000'); + self::drawText($el, $page, static fn (float $x, float $y): array => [$tx($x), $ty($y)]); + } + + /** + * SVG ``: x, y, font-size, fill, font-family, font-weight, + * font-style and text-anchor — taken from the element, its inline + * `style` or the nearest ancestor that sets them, as SVG inherits them. + * + * The glyphs are placed by a text matrix built from the SVG→PDF mapping + * of the text's own origin, so a `rotate()`, `scale()` or skew of a + * group turns and stretches the text with the shapes around it. + * + * Not supported: `` with its own position, ``, + * `dx`/`dy`, letter-spacing. + * + * @param callable(float, float): array{0: float, 1: float} $point SVG (x, y) → PDF (x, y) + */ + private static function drawText(\SimpleXMLElement $el, Page $page, callable $point): void + { + $text = trim((string) preg_replace('/\s+/u', ' ', (string) $el)); + if ($text === '') { + return; + } + [$fr, $fg, $fb, $hasFill] = self::parseColor(self::inheritedProperty($el, 'fill') ?? '#000'); if (! $hasFill) { return; } - // SVG text positioned at baseline; in PDF showText X-Y are used - // the same way (Y = baseline). - $text = trim((string) $el); - if ($text === '') { + $fontSize = (float) (self::inheritedProperty($el, 'font-size') ?? 16); + if ($fontSize <= 0) { return; } - $page->showText( - $text, $tx($x), $ty($y), - \Dskripchenko\PhpPdf\Pdf\StandardFont::Helvetica, - $fontSize * $scaleX, // Scale font size with x-scale. - $fr, $fg, $fb, - ); + $weight = strtolower((string) self::inheritedProperty($el, 'font-weight')); + $bold = $weight === 'bold' || $weight === 'bolder' || (is_numeric($weight) && (int) $weight >= 600); + $italic = in_array(strtolower((string) self::inheritedProperty($el, 'font-style')), ['italic', 'oblique'], true); + $family = self::inheritedProperty($el, 'font-family'); + $family = $family !== null ? trim($family) : null; + + $font = self::$fontResolver !== null ? (self::$fontResolver)($family !== '' ? $family : null, $bold, $italic, $text) : null; + $font ??= match (true) { + $bold && $italic => StandardFont::HelveticaBoldOblique, + $bold => StandardFont::HelveticaBold, + $italic => StandardFont::HelveticaOblique, + default => StandardFont::Helvetica, + }; + + // Lengths in SVG user units: the text matrix maps them onto the page. + $x = (float) ($el['x'] ?? 0); + $y = (float) ($el['y'] ?? 0); + $anchor = strtolower((string) self::inheritedProperty($el, 'text-anchor')); + if ($anchor === 'middle' || $anchor === 'end') { + $width = self::textWidth($text, $font, $fontSize); + $x -= $anchor === 'middle' ? $width / 2 : $width; + } + + // One SVG unit along the baseline and one up the glyph (SVG Y grows + // down, so "up" is -y), both mapped onto the page: the text matrix. + [$ox, $oy] = $point($x, $y); + [$rx, $ry] = $point($x + 1, $y); + [$ux, $uy] = $point($x, $y - 1); + $matrix = [$rx - $ox, $ry - $oy, $ux - $ox, $uy - $oy, $ox, $oy]; + + $page->showTextWithMatrix($text, $matrix, $font, $fontSize, $fr, $fg, $fb); + } + + /** Width of the text at the size, in the same units as the size. */ + private static function textWidth(string $text, StandardFont|PdfFont $font, float $size): float + { + if ($font instanceof PdfFont) { + return (new TextMeasurer($font, $size))->widthPt($text); + } + if (! StandardFontMetrics::has($font->value)) { + return mb_strlen($text, 'UTF-8') * $size * 0.5; + } + $units = 0; + $bytes = WinAnsi::encodeLossy($text); + for ($i = 0, $n = strlen($bytes); $i < $n; $i++) { + $units += StandardFontMetrics::width($font->value, ord($bytes[$i])); + } + + return $units * $size / 1000; + } + + /** + * A presentation property as SVG resolves it: the element's inline + * `style`, then its attribute, then the same on the nearest ancestor. + */ + private static function inheritedProperty(\SimpleXMLElement $el, string $name): ?string + { + // xpath() gives document order, root first: the nearest is the last. + $chain = array_reverse($el->xpath('ancestor-or-self::*') ?: []); + foreach ($chain as $node) { + $style = (string) ($node['style'] ?? ''); + if ($style !== '' && preg_match('/(?:^|;)\s*'.preg_quote($name, '/').'\s*:\s*([^;]+)/i', $style, $m) === 1) { + return trim($m[1]); + } + if (isset($node[$name])) { + return (string) $node[$name]; + } + } + + return null; } /** diff --git a/tests/Svg/SvgTextFontTest.php b/tests/Svg/SvgTextFontTest.php new file mode 100644 index 0000000..6004e0c --- /dev/null +++ b/tests/Svg/SvgTextFontTest.php @@ -0,0 +1,120 @@ +toBytes($engine ?? new Engine(compressStreams: false)); + } + + private function pdfText(string $bytes): string + { + $tmp = tempnam(sys_get_temp_dir(), 'svgt-'); + file_put_contents($tmp, $bytes); + try { + return (string) shell_exec('pdftotext '.escapeshellarg($tmp).' - 2>/dev/null'); + } finally { + @unlink($tmp); + } + } + + /** @return list> the operands of every `Tm` */ + private function textMatrices(string $bytes): array + { + preg_match_all('/(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) Tm/', $bytes, $m, PREG_SET_ORDER); + + return array_map(static fn (array $row): array => array_map('floatval', array_slice($row, 1)), $m); + } + + #[Test] + public function cyrillic_text_is_shown_in_the_document_font(): void + { + $svg = 'ОПЛАЧЕНО'; + $bytes = $this->render($svg, new Engine(compressStreams: false, defaultFont: $this->font())); + + self::assertStringContainsString('LiberationSans', $bytes); + self::assertStringContainsString('ОПЛАЧЕНО', $this->pdfText($bytes)); + } + + #[Test] + public function the_family_and_weight_are_inherited_from_the_group_and_style(): void + { + $svg = 'Жирный' + .'Обычный'; + $bytes = $this->render($svg, new Engine(compressStreams: false, defaultFont: $this->font(), boldFont: $this->font('Bold'))); + + self::assertStringContainsString('LiberationSans-Bold', $bytes); + self::assertMatchesRegularExpression('/\/F\d+ 20 Tf/', $bytes); + $text = $this->pdfText($bytes); + self::assertStringContainsString('Жирный', $text); + self::assertStringContainsString('Обычный', $text); + } + + #[Test] + public function text_anchor_moves_the_text_by_its_width(): void + { + $at = fn (string $anchor): float => $this->textMatrices($this->render( + 'Anchor', + ))[0][4]; + + $start = $at('start'); + $middle = $at('middle'); + $end = $at('end'); + + self::assertLessThan($start, $middle); + self::assertEqualsWithDelta($start - $end, 2 * ($start - $middle), 0.01); + } + + #[Test] + public function a_rotated_group_turns_the_text(): void + { + $bytes = $this->render('PAID'); + + [$a, $b] = $this->textMatrices($bytes)[0]; + // rotate(-10) in SVG (y down) is +10° counter-clockwise on the page. + self::assertEqualsWithDelta(atan2($b, $a), deg2rad(10), 1e-3); + self::assertStringContainsString('(PAID) Tj', $bytes); + } + + #[Test] + public function without_an_embedded_font_latin_text_still_uses_base_14(): void + { + $bytes = $this->render('Bold'); + + self::assertStringContainsString('/BaseFont /Helvetica-Bold', $bytes); + self::assertStringContainsString('(Bold) Tj', $bytes); + } +}