diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d43b9..736c04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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`. +- 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 ### Added diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index c8c392f..ba1fc61 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 .= ''; } @@ -354,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); + } +} 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; + } +}