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;
+ }
+}