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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<text>` 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 `<text>` 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
Expand Down
10 changes: 10 additions & 0 deletions docs/de/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,16 @@ Bézierkurven), Formen (`<rect>`, `<circle>`, `<ellipse>`, `<line>`,
Transformationen (`translate`, `scale`, `rotate`, `skewX`, `skewY`,
`matrix`), `<use>` / `<defs>`, Styling über CSS-Klassen.

`<text>` 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 `<tspan>`, `<textPath>`, `dx`/`dy`.

Inline-SVG funktioniert auch innerhalb von HTML — `<svg>`-Tags werden
an den SVG-Renderer durchgereicht.

Expand Down
9 changes: 9 additions & 0 deletions docs/en/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,15 @@ shapes (`<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polyline>`,
(`translate`, `scale`, `rotate`, `skewX`, `skewY`, `matrix`),
`<use>` / `<defs>`, CSS class styling.

`<text>` 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: `<tspan>` positioning,
`<textPath>`, `dx`/`dy`.

Inline SVG works inside HTML too — `<svg>` tags pass through to the
SVG renderer.

Expand Down
9 changes: 9 additions & 0 deletions docs/ru/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,15 @@ DocumentBuilder::new()
`skewX`, `skewY`, `matrix`), `<use>` / `<defs>`, стилизация через
CSS-классы.

`<text>` набирается шрифтами документа — той же цепочкой семейств,
запасными шрифтами и начертаниями, что и абзац, — поэтому кириллица и другие
нелатинские письменности печатаются как в тексте; без встроенного шрифта
текст уходит в шрифт base-14, в котором есть только латиница. `font-family`,
`font-weight`, `font-style`, `font-size`, `fill` и `text-anchor` берутся у
элемента, из его `style` или у ближайшего предка; `transform` группы
поворачивает и масштабирует глифы вместе с фигурами. Не поддерживаются:
позиционирование `<tspan>`, `<textPath>`, `dx`/`dy`.

Инлайн-SVG работает и внутри HTML — теги `<svg>` пробрасываются в
SVG-рендерер.

Expand Down
7 changes: 7 additions & 0 deletions docs/zh/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,13 @@ DocumentBuilder::new()
(`translate`、`scale`、`rotate`、`skewX`、`skewY`、`matrix`)、
`<use>` / `<defs>`、CSS 类样式。

`<text>` 使用文档的字体排版 —— 与段落相同的字体族链、后备字体以及粗体/斜体
字形 —— 因此西里尔字母及其他非拉丁文字的显示与正文一致;没有嵌入字体时,
会退回到只含拉丁字形的 base-14 字体。`font-family`、`font-weight`、
`font-style`、`font-size`、`fill` 与 `text-anchor` 从元素本身、其 `style`
或最近的祖先元素读取;组的 `transform` 会让字形随图形一起旋转和缩放。
不支持:`<tspan>` 定位、`<textPath>`、`dx`/`dy`。

行内 SVG 在 HTML 中同样有效 —— `<svg>` 标签会直接交给 SVG
渲染器处理。

Expand Down
11 changes: 10 additions & 1 deletion src/Layout/Engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/Pdf/ContentStream.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
27 changes: 27 additions & 0 deletions src/Pdf/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<text>` 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).
*
Expand Down
168 changes: 129 additions & 39 deletions src/Svg/SvgRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<text>`: (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 `<text>`. 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,
Expand Down Expand Up @@ -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 <text>. 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 `<text>`: 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: `<tspan>` with its own position, `<textPath>`,
* `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;
}

/**
Expand Down
Loading
Loading