Skip to content

Commit f854ea1

Browse files
mattmilesiclaude
andcommitted
feat: drop hidden (w:vanish) text by default
Parse the w:vanish ("Hidden") font effect into a new Run::$isHidden flag and add an ignoreHiddenText option to Converter and DocumentConverter. When enabled (the default), hidden runs are omitted from the HTML and Markdown output so the result matches what Word shows on screen and in print. The parsed Document still keeps the runs (flagged isHidden) so transformDocument callbacks can inspect them. This diverges from mammoth.js, which keeps hidden text; pass ignoreHiddenText: false to restore the previous behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c0a7f1d commit f854ea1

8 files changed

Lines changed: 104 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,27 @@ Tracked in `ROADMAP.md`. Highlights still pending: DSL list
1111
matchers (`p:unordered-list(N)`); custom `underline` mappers; CLI
1212
`--style-map FILE`; track-changes deletion concatenation.
1313

14+
## [0.4.0] — 2026-06-28
15+
16+
### Added
17+
18+
- **Hidden-text handling**: runs carrying the `w:vanish` ("Hidden")
19+
font effect are now parsed into a new `Run::$isHidden` flag, and a
20+
`Converter` / `DocumentConverter` option `ignoreHiddenText` controls
21+
whether they are rendered. The `w:vanish` element follows the usual
22+
boolean toggle rules (`w:val="false"` / `"0"` disables the effect).
23+
24+
### Changed
25+
26+
- **Hidden text is dropped by default**: with `ignoreHiddenText` on
27+
(the default), runs marked `w:vanish` are omitted from the HTML and
28+
Markdown output, so it matches what Word shows on screen and in
29+
print. The parsed `Document` still keeps the runs (flagged
30+
`isHidden`) so `transformDocument` callbacks can inspect them. This
31+
diverges from mammoth.js, which keeps hidden text. Pass
32+
`ignoreHiddenText: false` to restore the previous behaviour and emit
33+
hidden runs as ordinary text.
34+
1435
## [0.3.1] — 2026-04-28
1536

1637
### Fixed

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
(`\n`, `\r`, `\t`, `\\`, `\'`) inside string literals.
3737
- `Converter` options: `idPrefix` (namespace HTML ids),
3838
`ignoreEmptyParagraphs`, `prettyPrint` (indented HTML),
39+
`ignoreHiddenText` (drop `w:vanish` "Hidden" runs, on by default),
3940
`transformDocument` (callback to rewrite the parsed tree before
4041
rendering), plus `Transforms` helpers
4142
(`paragraph`, `run`, `elementsOfType`, `elements`, `getDescendants`,

‎src/Converter.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ public function __construct(
8888
// for block elements. Affects only `convertToHtml`; ignored by
8989
// Markdown and raw-text conversion.
9090
private readonly bool $prettyPrint = false,
91+
// When true (the default), runs carrying the `w:vanish`
92+
// ("Hidden") font effect are dropped so the output matches what
93+
// Word shows on screen. This diverges from mammoth.js, which
94+
// keeps hidden text. Set to false to emit hidden runs as text.
95+
private readonly bool $ignoreHiddenText = true,
9196
// Optional callback that receives the parsed `Document` and
9297
// returns a (typically modified) `Document` to convert in its
9398
// place. Mirrors mammoth's `transformDocument` hook -- useful
@@ -275,6 +280,7 @@ private function convert(string $path, callable $write): Result
275280
idPrefix: $this->idPrefix,
276281
ignoreEmptyParagraphs: $this->ignoreEmptyParagraphs,
277282
prettyPrint: $this->prettyPrint,
283+
ignoreHiddenText: $this->ignoreHiddenText,
278284
);
279285
$htmlResult = $write($converter, $document);
280286

‎src/Document/DocumentConverter.php‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ public function __construct(
6565
// identical because browsers collapse the added whitespace
6666
// outside of `<pre>`.
6767
private readonly bool $prettyPrint = false,
68+
// When true (the default), runs marked with the `w:vanish`
69+
// ("Hidden") font effect are dropped, matching how Word renders
70+
// them on screen and in print. This diverges from mammoth.js,
71+
// which keeps hidden text. Set to false to emit hidden runs as
72+
// ordinary text.
73+
private readonly bool $ignoreHiddenText = true,
6874
) {
6975
$this->styleMap = $styleMap ?? StyleMap::default();
7076
$this->comments = new Comments();
@@ -669,6 +675,10 @@ private static function wrapAsListItem(NumberingLevel $numbering, array $childre
669675
*/
670676
private function convertRun(Run $run, array &$messages): array
671677
{
678+
if ($this->ignoreHiddenText && $run->isHidden) {
679+
return [];
680+
}
681+
672682
$nodes = $this->convertNodes($run->children, $messages);
673683

674684
// Inline styling wrappers, innermost first to outermost, matching

‎src/Document/Run.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public function __construct(
2121
public bool $isStrikethrough = false,
2222
public bool $isAllCaps = false,
2323
public bool $isSmallCaps = false,
24+
public bool $isHidden = false,
2425
public VerticalAlignment $verticalAlignment = VerticalAlignment::Baseline,
2526
public ?string $highlight = null,
2627
public ?string $font = null,
@@ -45,6 +46,7 @@ public function withChildren(array $children): self
4546
isStrikethrough: $this->isStrikethrough,
4647
isAllCaps: $this->isAllCaps,
4748
isSmallCaps: $this->isSmallCaps,
49+
isHidden: $this->isHidden,
4850
verticalAlignment: $this->verticalAlignment,
4951
highlight: $this->highlight,
5052
font: $this->font,

‎src/Reader/BodyReader.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ private function readRun(Element $element): Result
332332
isStrikethrough: self::readBoolean($properties->first('w:strike')),
333333
isAllCaps: self::readBoolean($properties->first('w:caps')),
334334
isSmallCaps: self::readBoolean($properties->first('w:smallCaps')),
335+
isHidden: self::readBoolean($properties->first('w:vanish')),
335336
verticalAlignment: self::readVerticalAlignment($properties->first('w:vertAlign')),
336337
highlight: self::readHighlight($properties->first('w:highlight')),
337338
font: $properties->first('w:rFonts')?->attribute('w:ascii'),
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use EndlessCreativity\ElephantPhp\Document\Document;
6+
use EndlessCreativity\ElephantPhp\Document\DocumentConverter;
7+
use EndlessCreativity\ElephantPhp\Document\Paragraph;
8+
use EndlessCreativity\ElephantPhp\Document\Run;
9+
use EndlessCreativity\ElephantPhp\Document\Text;
10+
11+
function htmlOfDocument(Document $document, bool $ignoreHiddenText = true): string
12+
{
13+
return (new DocumentConverter(ignoreHiddenText: $ignoreHiddenText))
14+
->convertToHtml($document)
15+
->value;
16+
}
17+
18+
/**
19+
* @param list<Run> $runs
20+
*/
21+
function paragraphWithRuns(array $runs): Document
22+
{
23+
return new Document(children: [new Paragraph(children: $runs)]);
24+
}
25+
26+
it('drops a hidden run by default, keeping surrounding visible text', function (): void {
27+
$document = paragraphWithRuns([
28+
new Run(children: [new Text(value: 'before')]),
29+
new Run(isHidden: true, children: [new Text(value: 'SECRET')]),
30+
new Run(children: [new Text(value: 'after')]),
31+
]);
32+
33+
expect(htmlOfDocument($document))->toBe('<p>beforeafter</p>');
34+
});
35+
36+
it('emits a hidden run as ordinary text when ignoreHiddenText is false', function (): void {
37+
$document = paragraphWithRuns([
38+
new Run(children: [new Text(value: 'before')]),
39+
new Run(isHidden: true, children: [new Text(value: 'SECRET')]),
40+
new Run(children: [new Text(value: 'after')]),
41+
]);
42+
43+
expect(htmlOfDocument($document, ignoreHiddenText: false))
44+
->toBe('<p>beforeSECRETafter</p>');
45+
});
46+
47+
it('drops a paragraph whose only content is a hidden run', function (): void {
48+
$document = paragraphWithRuns([
49+
new Run(isHidden: true, children: [new Text(value: 'entirely hidden')]),
50+
]);
51+
52+
expect(htmlOfDocument($document))->toBe('');
53+
});
54+
55+
it('leaves a visible run untouched', function (): void {
56+
$document = paragraphWithRuns([
57+
new Run(children: [new Text(value: 'visible')]),
58+
]);
59+
60+
expect(htmlOfDocument($document))->toBe('<p>visible</p>');
61+
});

‎tests/Unit/Reader/BodyReaderRunPropertiesTest.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ function readRun(Element $runXml): Run
3939
expect($run->isStrikethrough)->toBeFalse();
4040
expect($run->isAllCaps)->toBeFalse();
4141
expect($run->isSmallCaps)->toBeFalse();
42+
expect($run->isHidden)->toBeFalse();
4243
expect($run->verticalAlignment)->toBe(VerticalAlignment::Baseline);
4344
});
4445

@@ -49,6 +50,7 @@ function readRun(Element $runXml): Run
4950
'isStrikethrough' => ['isStrikethrough', 'w:strike'],
5051
'isAllCaps' => ['isAllCaps', 'w:caps'],
5152
'isSmallCaps' => ['isSmallCaps', 'w:smallCaps'],
53+
'isHidden' => ['isHidden', 'w:vanish'],
5254
]);
5355

5456
it('treats a bare property element as enabling the flag (except w:u which needs a value)', function (string $property, string $tagName): void {

0 commit comments

Comments
 (0)