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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<w:caps/>` and `<w:smallCaps/>` 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
Expand Down
14 changes: 13 additions & 1 deletion src/Writer/BodyXmlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 .= '<w:spacing '.implode(' ', $attrs).'/>';
}
Expand Down Expand Up @@ -354,6 +360,12 @@ private function renderRunProperties(RunStyle $s): string
if ($s->italic) {
$rPr .= '<w:i/><w:iCs/>';
}
if ($s->allCaps) {
$rPr .= '<w:caps/>';
}
if ($s->smallCaps) {
$rPr .= '<w:smallCaps/>';
}
if ($s->underline) {
$rPr .= '<w:u w:val="single"/>';
}
Expand Down
70 changes: 70 additions & 0 deletions tests/Writer/CapsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Dskripchenko\PhpDocx\Tests\Writer;

use Dskripchenko\PhpDocx\Document;
use Dskripchenko\PhpDocx\Element\Paragraph;
use Dskripchenko\PhpDocx\Element\Run;
use Dskripchenko\PhpDocx\Reader\DocxReader;
use Dskripchenko\PhpDocx\Section;
use Dskripchenko\PhpDocx\Style\RunStyle;
use Dskripchenko\PhpDocx\Writer\BodyXmlBuilder;
use Dskripchenko\PhpDocx\Writer\Word2007Writer;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
* RunStyle::$allCaps / $smallCaps were parsed by the reader and serialized to
* HTML, but the writer never emitted `<w:caps/>` / `<w:smallCaps/>`, 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('<w:rPr><w:caps/></w:rPr><w:t xml:space="preserve">a</w:t>', $xml);
self::assertStringContainsString('<w:rPr><w:smallCaps/></w:rPr><w:t xml:space="preserve">b</w:t>', $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('<w:i/><w:iCs/><w:caps/><w:smallCaps/>', $xml);
self::assertLessThan(strpos($xml, '<w:strike/>'), strpos($xml, '<w:smallCaps/>'));
}

#[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);
}
}
109 changes: 109 additions & 0 deletions tests/Writer/LineSpacingRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);

namespace Dskripchenko\PhpDocx\Tests\Writer;

use Dskripchenko\PhpDocx\Build\DocumentBuilder;
use Dskripchenko\PhpDocx\Element\Paragraph;
use Dskripchenko\PhpDocx\Element\Run;
use Dskripchenko\PhpDocx\Reader\DocxReader;
use Dskripchenko\PhpDocx\Style\ParagraphStyle;
use Dskripchenko\PhpDocx\Writer\BodyXmlBuilder;
use Dskripchenko\PhpDocx\Writer\Word2007Writer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
* `w:lineRule` decides what `w:line` means: under `auto` it is a fraction of
* single spacing (240 = single, 276 = 1.15), under `exact`/`atLeast` a height
* in twips. The writer used to write `exact` whatever
* ParagraphStyle::$lineSpacingRule said, so a document read with 1.15
* spacing came back with lines fixed at 13.8pt, and larger text was clipped.
*/
final class LineSpacingRuleTest extends TestCase
{
public static function rules(): array
{
return [
'auto' => ['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('<w:spacing w:line="276" w:lineRule="'.$expected.'"/>', $xml);
}

/**
* @return array<string, array{string}>
*/
public static function autoSpacings(): array
{
return [
'explicit auto' => ['<w:spacing w:line="276" w:lineRule="auto"/>'],
// The schema default for a missing lineRule is auto.
'no lineRule' => ['<w:spacing w:line="276"/>'],
];
}

#[Test]
#[DataProvider('autoSpacings')]
public function auto_line_spacing_survives_read_then_write(string $spacing): void
{
$source = self::docxWithBody('<w:p><w:pPr>'.$spacing.'</w:pPr><w:r><w:t>x</w:t></w:r></w:p>');

$document = (new DocxReader)->read($source);
$written = self::documentXml((new Word2007Writer)->write($document));

self::assertStringContainsString('<w:spacing w:line="276" w:lineRule="auto"/>', $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',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
.'<w:body>'.$bodyInner.'</w:body></w:document>',
);
$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;
}
}
Loading