From d7945e2707fd6b3bd51058bf7077b053532db8a0 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 15 Nov 2026 02:34:54 +0900 Subject: [PATCH 1/5] fix(docx): count pages in a first-page header's table on the built-in renderer (#1152) A PAGE or NUMPAGES field in a table of a first-page header kept the number Word last saved ("Page 1 of 2" in a three-page document), because rhwp leaves such a number blank. The field is now read as a page number that also holds Word's text: the renderer counts the pages, and the HWPX writer still writes Word's text for rhwp. Co-Authored-By: Claude Opus 5.5 --- src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs | 24 ++++++++++++++-- src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs | 6 ++++ src/Filee.Engines/Hwp/Hwpx/HwpxWriter.Body.cs | 4 +++ .../Filee.Engines.Tests/ReportedCasesTests.cs | 28 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs index 2561f2deb..48161e91a 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs @@ -885,6 +885,10 @@ private void RunChild(XElement child, ParagraphBuilder builder, HCharFormat form foreach (var text in SymbolFonts.Runs(child.Value, format)) builder.Add(text); } + else if (StoredResult() is { } stored && stored.Stored!.Length < 32) + { + stored.Stored += child.Value; + } break; case "tab" or "ptab": if (ShowsText()) @@ -1123,11 +1127,20 @@ private sealed class FieldState public bool Result; public bool Suppress; public string? Target; + + /// The page number field whose result text is kept as . + public HField? Stored; } /// Field result text is shown unless we are inside an instruction or a field we replace (PAGE ...). private bool ShowsText() => _fields.All(f => f.Result && !f.Suppress); + /// The page number whose result text is being read, when only that field hides it. + private HField? StoredResult() => + _fields.Count > 0 && _fields[^1] is { Result: true, Stored: { } stored } && _fields.Take(_fields.Count - 1).All(f => f.Result && !f.Suppress) + ? stored + : null; + private void FieldChar(XElement fldChar, ParagraphBuilder builder, HCharFormat format) { switch (Attr(fldChar, "fldCharType")) @@ -1165,8 +1178,15 @@ private void StartFieldResult(FieldState field, ParagraphBuilder builder, HCharF switch (words.FirstOrDefault()?.ToUpperInvariant()) { case "PAGE" or "NUMPAGES" or "SECTIONPAGES" when _inFirstPageHeader && _tableDepth > 0: - // rhwp leaves a page number in a table in a text box blank: Word's result, which comes next, stays. - break; + { + // rhwp leaves a page number in a table in a text box blank: the HWPX writer writes Word's result, + // which comes next, as text. The layout engine counts the pages, as for any other page number. + var number = new HField(words[0].Equals("PAGE", StringComparison.OrdinalIgnoreCase) ? HFieldKind.PageNumber : HFieldKind.TotalPages, format) { Stored = "" }; + builder.Add(number); + field.Stored = number; + field.Suppress = true; + break; + } case "HYPERLINK": { // HYPERLINK "url" [\l "bookmark"] or HYPERLINK \l "bookmark" diff --git a/src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs b/src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs index 0ffcef3f9..35b7e2e54 100644 --- a/src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs +++ b/src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs @@ -577,6 +577,12 @@ internal sealed class HField(HFieldKind kind, HCharFormat format) : HInline /// The number's OWPML format (DIGIT, LATIN_CAPITAL, ROMAN_SMALL, ...); null for digits. Read for layout only. public string? NumberFormat { get; init; } + + /// + /// The number Word last saved for the field, which the HWPX writer writes as text instead of the field (rhwp leaves + /// a page number in a table in a text box blank); null when it writes the field. The layout engine always counts. + /// + public string? Stored { get; set; } } /// diff --git a/src/Filee.Engines/Hwp/Hwpx/HwpxWriter.Body.cs b/src/Filee.Engines/Hwp/Hwpx/HwpxWriter.Body.cs index dd79f53e1..9dfbe936e 100644 --- a/src/Filee.Engines/Hwp/Hwpx/HwpxWriter.Body.cs +++ b/src/Filee.Engines/Hwp/Hwpx/HwpxWriter.Body.cs @@ -286,6 +286,10 @@ private void Inlines(IEnumerable inlines, RunBuilder runs, string charP case HBookmark bookmark: runs.Control(charPrBase, $""); break; + case HField { Stored: { } stored } field: + if (stored.Length > 0) + runs.Text(CharPr(charPrBase, field.Format, link), Escape(stored)); + break; case HField field: runs.Control(CharPr(charPrBase, field.Format), AutoNumber(field.Kind == HFieldKind.PageNumber ? "PAGE" : "TOTAL_PAGE", 1, "")); break; diff --git a/tests/Filee.Engines.Tests/ReportedCasesTests.cs b/tests/Filee.Engines.Tests/ReportedCasesTests.cs index 8ed61fa64..104cd532e 100644 --- a/tests/Filee.Engines.Tests/ReportedCasesTests.cs +++ b/tests/Filee.Engines.Tests/ReportedCasesTests.cs @@ -15,6 +15,7 @@ using Filee.Core.Presets; using Filee.Engines.Documents; using Filee.Engines.Hwp.Hwpx; +using Filee.Engines.Hwp.Hwpx.Docx; using Filee.Engines.Office; using Filee.Engines.Office.Sheets; using Filee.Engines.Rendering; @@ -147,6 +148,33 @@ public async Task Issue1152_a_first_page_header_with_content_shows_on_page_one() Assert.Contains("Runningheader", Text(pages[1])); } + [Fact] + public async Task Issue1152_page_x_of_y_in_a_first_page_header_table_counts_every_page() + { + // The Doc Ref table of a first-page header: "Page 1 of 2" where Word last saved two pages, in a three-page + // document. The HWPX writer keeps Word's text there (rhwp leaves such a number blank); the renderer counts. + var docx = Docx(P(R("first")) + PageBreak + P(R("second")) + PageBreak + P(R("third"))); + var cell = P(R("Page ") + Field("PAGE", "1") + R(" of ") + Field("NUMPAGES", "2")); + var first = docx.Header("" + + $"{cell}" + P("")); + docx.SectionProperties = $"{DefaultPage}"; + var path = docx.Save(Path.Combine(fx.NewFolder(), "case.docx")); + + var pages = await ComposeAsync(path, "docx"); + + Assert.Equal(3, pages.Count); + Assert.Contains("Page1of3", Text(pages[0])); + var document = DocxReader.Read(path, fx.NewFolder()); + var header = Assert.IsType>(Assert.Single(document.Sections).FirstPageHeader); + var fields = Assert.IsType(header[0]).Rows[0].Cells[0].Blocks.OfType().SelectMany(p => p.Inlines).OfType().ToList(); + Assert.Equal(["1", "2"], fields.Select(f => f.Stored)); + var hwpx = Path.Combine(fx.NewFolder(), "case.hwpx"); + HwpxWriter.Write(document, hwpx); + using var zip = ZipFile.OpenRead(hwpx); + using var section = new StreamReader(zip.GetEntry("Contents/section0.xml")!.Open()); + Assert.Contains("Page 1 of 2", await section.ReadToEndAsync(Token), StringComparison.Ordinal); + } + [Fact] public async Task Issue1152_a_header_taller_than_the_top_margin_moves_the_body_down() { From efaa69ede941a5c7635a5f7293a1820b6c1a96f4 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 15 Nov 2026 02:51:26 +0900 Subject: [PATCH 2/5] fix(docx): draw text whose document names no font in Times New Roman (#1180) When a document's defaults and styles name no font at all, Word draws its Latin and complex-script text in Times New Roman. The renderer fell back to its own sans serif, which is wider: one of the worksheets ran two pages longer. Documents that name a font or a theme font keep it. Co-Authored-By: Claude Opus 5.5 --- .../Hwp/Hwpx/Docx/DocxReader.Styles.cs | 17 +++++++++++++ src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs | 1 + .../Filee.Engines.Tests/ReportedCasesTests.cs | 24 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs index 39c9201ea..a03b56ce5 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs @@ -305,6 +305,23 @@ private sealed class NumberingLevel private (string? Latin, string? EastAsian) _minorFont, _majorFont; private string? _minorComplexFont, _majorComplexFont; + /// + /// Word draws text whose styles and document defaults name no font in Times New Roman (Latin and complex script + /// characters); East Asian text keeps the font of its language. + /// + private void DefaultFonts() + { + var run = _defaultRun; + if (run.Ascii is not null || run.AsciiTheme is not null || run.AsciiOnly is not null || run.AsciiOnlyTheme is not null + || run.HighAnsi is not null || run.HighAnsiTheme is not null) + return; + run.Ascii = run.AsciiOnly = run.HighAnsi = WordDefaultFont; + if (run.ComplexScript is null && run.ComplexScriptTheme is null) + run.ComplexScript = WordDefaultFont; + } + + private const string WordDefaultFont = "Times New Roman"; + private void LoadStyles(Part? part) { var root = part?.Xml.Root; diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs index 48161e91a..241a35299 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs @@ -205,6 +205,7 @@ private HDocument ReadDocument() { var main = _part; LoadStyles(RelatedPart(main, "/styles")); + DefaultFonts(); var theme = RelatedPart(main, "/theme"); LoadTheme(theme); LoadThemeColors(theme); diff --git a/tests/Filee.Engines.Tests/ReportedCasesTests.cs b/tests/Filee.Engines.Tests/ReportedCasesTests.cs index 104cd532e..8c43637f5 100644 --- a/tests/Filee.Engines.Tests/ReportedCasesTests.cs +++ b/tests/Filee.Engines.Tests/ReportedCasesTests.cs @@ -439,6 +439,30 @@ public void Issue356_a_word_document_with_an_ole_object_keeps_its_fallback_route Assert.Null(renderer.FirstUnsupported(plain, "docx")); } + [Fact] + public void Issue1180_text_in_a_document_that_names_no_font_is_in_times_new_roman() + { + // A worksheet whose styles and defaults name no font at all: Word draws it in Times New Roman, the renderer + // drew it in its own sans serif, wider, and the pages ran over. A theme or named font still wins. + const string Bare = """ + + + """; + var docx = new DocxBuilder { Styles = Bare }.Paragraph(P(R("Student Name")) + P(R("UID", ""))); + var themed = new DocxBuilder { Styles = Bare.Replace("().Select(p => p.Inlines.OfType().Single()).ToList(); + Assert.Equal("Times New Roman", texts[0].Format.Font); + Assert.Equal("Times New Roman", texts[0].Format.Layout?.WordFonts?.Ascii); + Assert.Equal("Times New Roman", texts[0].Format.Layout?.WordFonts?.ComplexScript); + Assert.Equal("Arial", texts[1].Format.Font); + var body = Assert.Single(theme.Sections).Blocks.OfType().Single().Inlines.OfType().Single(); + Assert.NotEqual("Times New Roman", body.Format.Font); + } + // ───────────────────────── Word: fixes that came with the main merge (#1194, #355, #1237) ───────────────────────── [Fact] From 96e7b3ad92d9209a0e8b98dc6ea1022dfa5227f5 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 15 Nov 2026 03:05:11 +0900 Subject: [PATCH 3/5] fix(docx): start the next page with the next paragraph after a trailing page break (#1194) A paragraph that ends right after its page break keeps its mark on the old page in Word. The reader made that empty rest a paragraph of its own at the top of the new page, so every chapter after such a break started a line and the paragraph's space after lower, and long reports ran pages over. The next paragraph or table now starts the page instead. A numbered paragraph or one that ends a section keeps its rest as before. Co-Authored-By: Claude Opus 5.5 --- src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs | 40 +++++++++++++++++-- .../Filee.Engines.Tests/ReportedCasesTests.cs | 25 ++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs index 241a35299..49d68210e 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs @@ -61,6 +61,12 @@ internal sealed partial class DocxReader /// Reading the header for the first page only, which becomes a text box (see ). private bool _inFirstPageHeader; + /// + /// The block list whose next block starts a new page: a paragraph ended right after its page break, and Word keeps + /// such a paragraph's mark on the old page. + /// + private List? _pageBreakFor; + /// Table cells, text boxes and notes around the blocks being read (see ). private int _depth; @@ -648,6 +654,8 @@ private void ReadNestedBlocks(XElement container, List output) private void ReadBlock(XElement element, List output) { + var count = output.Count; + var pageBreak = ReferenceEquals(_pageBreakFor, output); if (element.Name == W + "p") ReadParagraph(element, output); else if (element.Name == W + "tbl") @@ -656,6 +664,20 @@ private void ReadBlock(XElement element, List output) output.Add(ReadTable(element)); _tableDepth--; } + if (pageBreak && output.Count > count) + { + if (ReferenceEquals(_pageBreakFor, output)) + _pageBreakFor = null; + switch (output[count]) + { + case HParagraph paragraph: + paragraph.PageBreakBefore = true; + break; + case HTable table: + table.PageBreakBefore = true; + break; + } + } } // ───────────────────────── Paragraphs ───────────────────────── @@ -673,6 +695,7 @@ private sealed class ParagraphBuilder private readonly int _heading; private readonly HListRef? _list; private readonly ParaProps _props; + private bool _pageBroken; private readonly Stack> _sinks = new(); public ParagraphBuilder(DocxReader reader, List output, ParaProps props, RunProps mark, XElement? pPr) @@ -728,7 +751,7 @@ public void Break(bool page) _sinks.Clear(); } if (page) - Current.PageBreakBefore = true; + Current.PageBreakBefore = _pageBroken = true; else Current.ColumnBreakBefore = true; } @@ -744,7 +767,18 @@ public void NextRow() _sinks.Clear(); } - public void Finish() => Emit(); + /// The paragraph holds a section's properties (its section ends here). + public void Finish(bool endsSection = false) + { + // Word keeps the mark of a paragraph that ends right after a page break on the old page: the new page starts + // with the next paragraph, not with an empty line and the paragraph's space after. + if (_pageBroken && Current.Inlines.Count == 0 && Current.List is not { Numbered: true } && !endsSection) + { + _reader._pageBreakFor = _output; + return; + } + Emit(); + } private void Emit() { @@ -772,7 +806,7 @@ private void ReadParagraph(XElement p, List output) // Run formatting starts from the paragraph style, not from the paragraph mark's direct formatting. var styleRun = ParagraphStyle(Val(pPr?.Element(W + "pStyle")) ?? _defaultParagraphStyle).Run; ParagraphContent(p.Elements(), builder, styleRun); - builder.Finish(); + builder.Finish(endsSection: pPr?.Element(W + "sectPr") is not null); } private void ParagraphContent(IEnumerable elements, ParagraphBuilder builder, RunProps styleRun) diff --git a/tests/Filee.Engines.Tests/ReportedCasesTests.cs b/tests/Filee.Engines.Tests/ReportedCasesTests.cs index 8c43637f5..514a195d7 100644 --- a/tests/Filee.Engines.Tests/ReportedCasesTests.cs +++ b/tests/Filee.Engines.Tests/ReportedCasesTests.cs @@ -481,6 +481,31 @@ static string Styles(string contextual) => NotoStyles + Assert.Equal(single + 10, Pitch(loose), 0.1f); } + [Fact] + public async Task Issue1194_a_page_break_at_the_end_of_a_paragraph_leaves_no_empty_line_on_the_next_page() + { + // Each chapter of the report starts after a paragraph holding only a page break. Word keeps that paragraph's + // mark on the old page; the renderer put it on the new one, an empty line and 10 pt above every chapter. + var styles = NotoStyles.Replace("", + "", StringComparison.Ordinal); + Assert.NotEqual(NotoStyles, styles); + const string Break = ""; + var own = new DocxBuilder { Styles = styles, SectionProperties = DefaultPage }.Paragraph(P(R("Chapter 1")) + P(Break) + P(R("Chapter 2"))); + var ending = new DocxBuilder { Styles = styles, SectionProperties = DefaultPage }.Paragraph(P(R("Chapter 1") + Break) + P(R("Chapter 2"))); + var property = new DocxBuilder { Styles = styles, SectionProperties = DefaultPage }.Paragraph(P(R("Chapter 1")) + P(R("Chapter 2"), "")); + var middle = new DocxBuilder { Styles = styles, SectionProperties = DefaultPage }.Paragraph(P(R("Chapter 1") + Break + R("Chapter 2"))); + + var expected = Lines((await DocxAsync(property))[1]).Single().Y; + foreach (var docx in new[] { own, ending, middle }) + { + var pages = await DocxAsync(docx); + Assert.Equal(2, pages.Count); + var line = Assert.Single(Lines(pages[1])); + Assert.Equal("Chapter2", line.Text); + Assert.Equal(expected, line.Y, 0.1f); + } + } + [Fact] public async Task Issue355_an_equation_number_after_a_hash_sits_at_the_right_margin() { From 36f38de11231a4348c767be3e2a9aa15bad51253 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 15 Nov 2026 03:10:52 +0900 Subject: [PATCH 4/5] fix(docx): leave hidden VML shapes out and place page-positioned frames (#1313) - A VML shape with visibility:hidden (a form's spare copy of its WordArt) was drawn, squeezed into its 50 pt box over the title. - A paragraph in a frame (w:framePr) placed on the page or within the margins stayed in the text: the form's verification code came out under the signature in the middle of the page. Such paragraphs now go into a box of the frame's size at its place, floating at the paragraph before them, so they take no room in the text. Frames that move with the text and drop caps stay as they were. Co-Authored-By: Claude Opus 5.5 --- .../Hwp/Hwpx/Docx/DocxReader.Drawing.cs | 3 + src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs | 96 ++++++++++++++++++- .../Filee.Engines.Tests/ReportedCasesTests.cs | 26 +++++ .../Filee.Engines.Tests/WordWatermarkTests.cs | 18 ++++ 4 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs index 6ceb124af..9e7b4e4f8 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs @@ -573,6 +573,9 @@ private IEnumerable Vml(XElement container, HCharFormat format) continue; var style = VmlStyle((string?)shape.Attribute("style")); + // Word doesn't draw a hidden shape (a stamp's text kept for later, a template's spare WordArt). + if (style.GetValueOrDefault("visibility") == "hidden") + continue; var width = HwpxUnits.ParseLength(style.GetValueOrDefault("width")) ?? 0; var height = HwpxUnits.ParseLength(style.GetValueOrDefault("height")) ?? 0; var anchor = style.GetValueOrDefault("position") == "absolute" ? VmlAnchor(shape, style) : null; diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs index 49d68210e..958a793de 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs @@ -656,7 +656,9 @@ private void ReadBlock(XElement element, List output) { var count = output.Count; var pageBreak = ReferenceEquals(_pageBreakFor, output); - if (element.Name == W + "p") + if (element.Name == W + "p" && PageFrame(element) is { } frame) + ReadFramed(element, frame, output); + else if (element.Name == W + "p") ReadParagraph(element, output); else if (element.Name == W + "tbl") { @@ -680,6 +682,98 @@ private void ReadBlock(XElement element, List output) } } + // ───────────────────────── Frames ───────────────────────── + + /// The frame paragraphs are being read into, while the next paragraph with the same w:framePr joins it. + private (XElement Properties, HTextBox Box, List Output, int Count)? _frame; + + /// + /// The w:framePr of a paragraph Word sets in a frame of a given size at a place on the page or within the margins + /// (a verification code at the bottom of a form); null for other paragraphs, drop caps and frames that move with + /// the text, which stay in the text. + /// + private static XElement? PageFrame(XElement paragraph) + { + var frame = paragraph.Element(W + "pPr")?.Element(W + "framePr"); + if (frame is null || Attr(frame, "dropCap") is "drop" or "margin" || Attr(frame, "vAnchor") is not ("page" or "margin") + || Twips(frame, "w") is not > 0 || Twips(frame, "h") is not (> 0 or < 0)) + return null; + return frame; + } + + /// + /// A paragraph in a frame: a box without border or padding at the frame's place, floating at the paragraph before + /// it (the frame takes no room in the text). Consecutive paragraphs with the same frame share the box. + /// + private void ReadFramed(XElement paragraph, XElement properties, List output) + { + HTextBox box; + if (_frame is { } open && ReferenceEquals(open.Output, output) && open.Count == output.Count && XNode.DeepEquals(open.Properties, properties)) + { + box = open.Box; + } + else + { + var height = Math.Abs(Twips(properties, "h") ?? 0); + box = new TextBoxBuilder(HwpxUnits.FromTwips(Twips(properties, "w") ?? 0), HwpxUnits.FromTwips(height), FrameAnchor(properties)) + { + Line = HBorder.None, + Padding = new HInsets(0, 0, 0, 0), + }.Build(); + if (output.LastOrDefault() is HParagraph host) + { + host.Inlines.Add(box); + } + else + { + var empty = new HParagraph(); + empty.Inlines.Add(box); + output.Add(empty); + } + } + _textBoxDepth++; + try + { + ReadParagraph(paragraph, box.Blocks); + } + finally + { + _textBoxDepth--; + } + _frame = (properties, box, output, output.Count); + } + + /// Where w:framePr puts its frame: x/xAlign from the page, margin or column, y/yAlign from the page or margin. + private static HAnchor FrameAnchor(XElement frame) => new( + HorizontalRelativeTo: Attr(frame, "hAnchor") switch + { + "page" => "PAPER", + "margin" => "PAGE", + _ => "COLUMN", + }, + HorizontalAlign: Attr(frame, "xAlign") switch + { + "center" => "CENTER", + "right" or "outside" => "RIGHT", + _ => "LEFT", + }, + HorizontalOffset: Attr(frame, "xAlign") is null ? HwpxUnits.FromTwips(Twips(frame, "x") ?? 0) : 0, + VerticalRelativeTo: Attr(frame, "vAnchor") == "page" ? "PAPER" : "PAGE", + VerticalAlign: Attr(frame, "yAlign") switch + { + "center" => "CENTER", + "bottom" or "outside" => "BOTTOM", + _ => "TOP", + }, + VerticalOffset: Attr(frame, "yAlign") is null ? HwpxUnits.FromTwips(Twips(frame, "y") ?? 0) : 0, + Wrap: Attr(frame, "wrap") switch + { + "through" => "IN_FRONT_OF_TEXT", + "none" or "notBeside" => "TOP_AND_BOTTOM", + _ => "SQUARE", + }, + AllowOverlap: true); + // ───────────────────────── Paragraphs ───────────────────────── /// diff --git a/tests/Filee.Engines.Tests/ReportedCasesTests.cs b/tests/Filee.Engines.Tests/ReportedCasesTests.cs index 514a195d7..272868b8a 100644 --- a/tests/Filee.Engines.Tests/ReportedCasesTests.cs +++ b/tests/Filee.Engines.Tests/ReportedCasesTests.cs @@ -540,6 +540,32 @@ await File.WriteAllTextAsync(path, Assert.Equal(SKColors.Black, Runs(page).First(r => r.Text.Contains("Plain", StringComparison.Ordinal)).Color); } + [Fact] + public async Task Issue1313_a_frame_sits_where_its_page_position_puts_it() + { + // The form's verification code: a paragraph in a frame (w:framePr) 492 pt from the left and 720 pt from the top + // of the page, 84 × 98 pt, centred. It came out under the signature in the text, in the middle of the page. + var frame = ""; + var docx = Docx(P(R("Signature")) + P(R("Code"), frame) + P(R("Check"), frame) + P(R("After"))); + + var page = Single(await DocxAsync(docx)); + + var lines = Lines(page); + var signature = lines.Single(l => l.Text == "Signature"); + var after = lines.Single(l => l.Text == "After"); + Assert.Equal(LineHeight(RepositoryFonts.Latin, 10), after.Y - signature.Y, 0.5f); + var codeLine = lines.Single(l => l.Text == "Code"); + var code = Glyphs(page).Where(g => g.Text.Length > 0 && MathF.Round(g.Y, 1) == codeLine.Y).ToList(); + Assert.Equal(4, code.Count); + Assert.InRange(codeLine.Y, 720, 720 + 98); + Assert.InRange(code.Min(g => g.X), 492, 492 + 84); + Assert.InRange(code.Max(g => g.X), 492, 492 + 84); + // Centred in the frame, the two paragraphs one under the other. + Assert.InRange((code.Min(g => g.X) + code.Max(g => g.X)) / 2, 492 + 42 - 12, 492 + 42 + 12); + var check = lines.Single(l => l.Text == "Check"); + Assert.True(check.Y > code[0].Y && check.Y < 720 + 98, $"second line at {check.Y}"); + } + // ───────────────────────── Word: right-to-left documents (#202, #216, #620) ───────────────────────── [Fact] diff --git a/tests/Filee.Engines.Tests/WordWatermarkTests.cs b/tests/Filee.Engines.Tests/WordWatermarkTests.cs index c3c07cc2a..8d0c1b07e 100644 --- a/tests/Filee.Engines.Tests/WordWatermarkTests.cs +++ b/tests/Filee.Engines.Tests/WordWatermarkTests.cs @@ -77,6 +77,24 @@ public void A_vml_watermark_is_wordart_turned_half_see_through_and_centred_behin Assert.True(text.Format.Size > 5000, $"size {text.Format.Size}"); } + [Fact] + public void A_hidden_wordart_shape_is_left_out() + { + // A form kept a spare copy of its WordArt as a hidden 50 pt shape at the top left (visibility:hidden), next to + // the one it shows; Word draws only the visible one (#1313). + string Shape(string id, string visibility) => + $"" + + $""; + var body = $"{Shape("Spare", "hidden")}{Shape("Shown", "visible")}"; + + var document = Read(new DocxBuilder().Paragraph(body)); + + var boxes = document.Sections[0].Blocks.OfType().SelectMany(p => p.Inlines).OfType().ToList(); + var text = Assert.IsType(Assert.Single(Assert.IsType(Assert.Single(Assert.Single(boxes).Blocks)).Inlines)); + Assert.Equal("Shown", text.Text); + } + [Fact] public void A_picture_watermark_keeps_its_washout() { From b00c8e72631e7cd6daea67e62fad6a50f0c8678e Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 15 Nov 2026 03:28:50 +0900 Subject: [PATCH 5/5] fix(docx): draw table of contents entries without the Hyperlink style's blue (#1152) Word writes each entry of a table of contents as a link in the Hyperlink character style, yet draws the entries in the TOC styles' look, as LibreOffice does. The renderer drew every entry blue and underlined. In a TOC field's result the Hyperlink character style is now left out; links elsewhere keep it. Co-Authored-By: Claude Opus 5.5 --- .../Hwp/Hwpx/Docx/DocxReader.Styles.cs | 5 ++++- src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs | 9 ++++++++ .../Filee.Engines.Tests/ReportedCasesTests.cs | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs index a03b56ce5..72266c0bc 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs @@ -509,7 +509,10 @@ static void ApplyLevel(NumberingLevel[] levels, XElement lvl) private RunProps RunProperties(XElement? rPr, RunProps paragraphRun) { var run = paragraphRun.Clone(); - if (Val(rPr?.Element(W + "rStyle")) is { } characterStyle && _styles.TryGetValue(characterStyle, out var style)) + // Word and LibreOffice draw the entries of a table of contents in the TOC styles' look, though Word gives each + // entry's link the Hyperlink character style (blue, underlined). + if (Val(rPr?.Element(W + "rStyle")) is { } characterStyle && _styles.TryGetValue(characterStyle, out var style) + && !(InTableOfContents && (style.Id == "Hyperlink" || string.Equals(style.Name, "Hyperlink", StringComparison.OrdinalIgnoreCase)))) { // Character styles only carry run properties; apply their chain on top of the paragraph's. var chain = new List