From 50a6aaababd19673397ced1de19b6f2825e1d47a Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Mon, 19 Oct 2026 15:13:38 -0700 Subject: [PATCH 1/2] fix: emit w:lineRule from ParagraphStyle::$lineSpacingRule The writer wrote w:lineRule="exact" for every line spacing and never read lineSpacingRule. The reader sets the rule (auto when the attribute is missing, per the schema default), so read -> write turned Word's 1.15 spacing (line=276 auto) into a fixed 13.8pt line height. auto, exact and atLeast are now written as given. An unset or unknown rule stays exact, so callers that set only lineSpacingTwips (the builder's spacing(line:), the HTML converter) get the same output as before. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++ src/Writer/BodyXmlBuilder.php | 8 +- tests/Writer/LineSpacingRuleTest.php | 109 +++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 tests/Writer/LineSpacingRuleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d43b9..78717d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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 +- The writer now emits `w:lineRule` from `ParagraphStyle::$lineSpacingRule` + (`auto`, `exact`, `atLeast`); it wrote `exact` whatever the rule was. The + reader fills the rule in, so a document with Word's usual 1.15 spacing + (`w:line="276" w:lineRule="auto"`) came back from read → write with lines + fixed at 13.8 pt, and text larger than that was clipped. An unset rule + still writes `exact`. + ## [1.15.0] — 2026-10-19 ### Added diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index c8c392f..183eb77 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -250,7 +250,13 @@ private function renderParagraphProperties(ParagraphStyle $style, ?int $headingL } if ($style->lineSpacingTwips !== null) { $attrs[] = 'w:line="'.$style->lineSpacingTwips.'"'; - $attrs[] = 'w:lineRule="exact"'; + // Under `auto` the number is in 240ths of a single line, under + // `exact`/`atLeast` it is a height in twips. An unset or unknown + // rule stays `exact`, as the writer wrote it before. + $rule = in_array($style->lineSpacingRule, ['auto', 'exact', 'atLeast'], true) + ? $style->lineSpacingRule + : 'exact'; + $attrs[] = 'w:lineRule="'.$rule.'"'; } $pPr .= ''; } diff --git a/tests/Writer/LineSpacingRuleTest.php b/tests/Writer/LineSpacingRuleTest.php new file mode 100644 index 0000000..e5dadab --- /dev/null +++ b/tests/Writer/LineSpacingRuleTest.php @@ -0,0 +1,109 @@ + ['auto', 'auto'], + 'exact' => ['exact', 'exact'], + 'atLeast' => ['atLeast', 'atLeast'], + 'unset keeps exact' => [null, 'exact'], + 'unknown keeps exact' => ['bogus', 'exact'], + ]; + } + + #[Test] + #[DataProvider('rules')] + public function writer_emits_the_rule_from_the_style(?string $rule, string $expected): void + { + $xml = (new BodyXmlBuilder)->render([ + new Paragraph([new Run('x')], new ParagraphStyle(lineSpacingTwips: 276, lineSpacingRule: $rule)), + ]); + + self::assertStringContainsString('', $xml); + } + + /** + * @return array + */ + public static function autoSpacings(): array + { + return [ + 'explicit auto' => [''], + // The schema default for a missing lineRule is auto. + 'no lineRule' => [''], + ]; + } + + #[Test] + #[DataProvider('autoSpacings')] + public function auto_line_spacing_survives_read_then_write(string $spacing): void + { + $source = self::docxWithBody(''.$spacing.'x'); + + $document = (new DocxReader)->read($source); + $written = self::documentXml((new Word2007Writer)->write($document)); + + self::assertStringContainsString('', $written); + } + + /** + * A package the library writes, with word/document.xml swapped for the + * given body, the way Word would have written it. + */ + private static function docxWithBody(string $bodyInner): string + { + $bytes = DocumentBuilder::new()->paragraph('placeholder')->toBytes(); + $tmp = (string) tempnam(sys_get_temp_dir(), 'docx-rule-'); + file_put_contents($tmp, $bytes); + $zip = new \ZipArchive; + $zip->open($tmp); + $zip->addFromString( + 'word/document.xml', + '' + .'' + .''.$bodyInner.'', + ); + $zip->close(); + $out = (string) file_get_contents($tmp); + unlink($tmp); + + return $out; + } + + private static function documentXml(string $bytes): string + { + $tmp = (string) tempnam(sys_get_temp_dir(), 'docx-rule-'); + file_put_contents($tmp, $bytes); + $zip = new \ZipArchive; + $zip->open($tmp); + $xml = (string) $zip->getFromName('word/document.xml'); + $zip->close(); + unlink($tmp); + + return $xml; + } +} From ec04181c5d33efdb1bdc0d828d0300adbaeba7e4 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Mon, 19 Oct 2026 15:14:03 -0700 Subject: [PATCH 2/2] fix: emit w:caps and w:smallCaps for RunStyle RunStyle::$allCaps and $smallCaps were parsed by the reader and serialized to HTML (text-transform / font-variant), but the writer never emitted them, so a read -> write round-trip lost the formatting. Both are written in their CT_RPr position, after i/iCs and before strike. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ src/Writer/BodyXmlBuilder.php | 6 +++ tests/Writer/CapsTest.php | 70 +++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 tests/Writer/CapsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 78717d4..736c04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). (`w:line="276" w:lineRule="auto"`) came back from read → write with lines fixed at 13.8 pt, and text larger than that was clipped. An unset rule still writes `exact`. +- The writer now emits `` and `` for + `RunStyle::$allCaps` / `$smallCaps`. The reader parsed them and the HTML + serializer wrote them, but a read → write round-trip dropped them, and a + heading set in small caps came back in lowercase. ## [1.15.0] — 2026-10-19 diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index 183eb77..ba1fc61 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -360,6 +360,12 @@ private function renderRunProperties(RunStyle $s): string if ($s->italic) { $rPr .= ''; } + if ($s->allCaps) { + $rPr .= ''; + } + if ($s->smallCaps) { + $rPr .= ''; + } if ($s->underline) { $rPr .= ''; } diff --git a/tests/Writer/CapsTest.php b/tests/Writer/CapsTest.php new file mode 100644 index 0000000..e3ba959 --- /dev/null +++ b/tests/Writer/CapsTest.php @@ -0,0 +1,70 @@ +` / ``, so the + * formatting was lost on the way back into a DOCX. + */ +final class CapsTest extends TestCase +{ + #[Test] + public function writer_emits_caps_and_small_caps(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Paragraph([ + new Run('a', new RunStyle(allCaps: true)), + new Run('b', new RunStyle(smallCaps: true)), + ]), + ]); + + self::assertStringContainsString('a', $xml); + self::assertStringContainsString('b', $xml); + } + + #[Test] + public function caps_follow_italic_and_precede_strike(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Paragraph([new Run('x', new RunStyle(italic: true, strikethrough: true, allCaps: true, smallCaps: true))]), + ]); + + self::assertStringContainsString('', $xml); + self::assertLessThan(strpos($xml, ''), strpos($xml, '')); + } + + #[Test] + public function caps_survive_write_then_read(): void + { + $bytes = (new Word2007Writer)->write(new Document(new Section([ + new Paragraph([ + new Run('a', new RunStyle(allCaps: true)), + new Run('b', new RunStyle(smallCaps: true)), + ]), + ]))); + + $paragraph = (new DocxReader)->read($bytes)->section->body[0]; + self::assertInstanceOf(Paragraph::class, $paragraph); + [$a, $b] = $paragraph->children; + self::assertInstanceOf(Run::class, $a); + self::assertInstanceOf(Run::class, $b); + self::assertTrue($a->style->allCaps); + self::assertFalse($a->style->smallCaps); + self::assertTrue($b->style->smallCaps); + self::assertFalse($b->style->allCaps); + } +}