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
3 changes: 3 additions & 0 deletions src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,9 @@ private IEnumerable<HInline> 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;
Expand Down
22 changes: 21 additions & 1 deletion src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,23 @@ private sealed class NumberingLevel
private (string? Latin, string? EastAsian) _minorFont, _majorFont;
private string? _minorComplexFont, _majorComplexFont;

/// <summary>
/// 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.
/// </summary>
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;
Expand Down Expand Up @@ -492,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<Style>();
Expand Down
168 changes: 163 additions & 5 deletions src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ internal sealed partial class DocxReader
/// <summary>Reading the header for the first page only, which becomes a text box (see <see cref="HSection.FirstPageHeader"/>).</summary>
private bool _inFirstPageHeader;

/// <summary>
/// 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.
/// </summary>
private List<HBlock>? _pageBreakFor;

/// <summary>Table cells, text boxes and notes around the blocks being read (see <see cref="ReadNestedBlocks"/>).</summary>
private int _depth;

Expand Down Expand Up @@ -205,6 +211,7 @@ private HDocument ReadDocument()
{
var main = _part;
LoadStyles(RelatedPart(main, "/styles"));
DefaultFonts();
var theme = RelatedPart(main, "/theme");
LoadTheme(theme);
LoadThemeColors(theme);
Expand Down Expand Up @@ -647,16 +654,126 @@ private void ReadNestedBlocks(XElement container, List<HBlock> output)

private void ReadBlock(XElement element, List<HBlock> output)
{
if (element.Name == W + "p")
var count = output.Count;
var pageBreak = ReferenceEquals(_pageBreakFor, output);
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")
{
_tableDepth++;
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;
}
}
}

// ───────────────────────── Frames ─────────────────────────

/// <summary>The frame paragraphs are being read into, while the next paragraph with the same w:framePr joins it.</summary>
private (XElement Properties, HTextBox Box, List<HBlock> Output, int Count)? _frame;

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
private void ReadFramed(XElement paragraph, XElement properties, List<HBlock> 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);
}

/// <summary>Where w:framePr puts its frame: x/xAlign from the page, margin or column, y/yAlign from the page or margin.</summary>
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 ─────────────────────────

/// <summary>
Expand All @@ -672,6 +789,7 @@ private sealed class ParagraphBuilder
private readonly int _heading;
private readonly HListRef? _list;
private readonly ParaProps _props;
private bool _pageBroken;
private readonly Stack<List<HInline>> _sinks = new();

public ParagraphBuilder(DocxReader reader, List<HBlock> output, ParaProps props, RunProps mark, XElement? pPr)
Expand Down Expand Up @@ -727,7 +845,7 @@ public void Break(bool page)
_sinks.Clear();
}
if (page)
Current.PageBreakBefore = true;
Current.PageBreakBefore = _pageBroken = true;
else
Current.ColumnBreakBefore = true;
}
Expand All @@ -743,7 +861,18 @@ public void NextRow()
_sinks.Clear();
}

public void Finish() => Emit();
/// <param name="endsSection">The paragraph holds a section's properties (its section ends here).</param>
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()
{
Expand Down Expand Up @@ -771,7 +900,7 @@ private void ReadParagraph(XElement p, List<HBlock> 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<XElement> elements, ParagraphBuilder builder, RunProps styleRun)
Expand Down Expand Up @@ -885,6 +1014,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())
Expand Down Expand Up @@ -1123,11 +1256,26 @@ private sealed class FieldState
public bool Result;
public bool Suppress;
public string? Target;

/// <summary>The page number field whose result text is kept as <see cref="HField.Stored"/>.</summary>
public HField? Stored;

/// <summary>A table of contents (TOC): its entries don't take the Hyperlink character style's look.</summary>
public bool TableOfContents;
}

/// <summary>Text is in the entries of a table of contents.</summary>
private bool InTableOfContents => _fields.Any(f => f.Result && f.TableOfContents);

/// <summary>Field result text is shown unless we are inside an instruction or a field we replace (PAGE ...).</summary>
private bool ShowsText() => _fields.All(f => f.Result && !f.Suppress);

/// <summary>The page number whose result text is being read, when only that field hides it.</summary>
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"))
Expand Down Expand Up @@ -1165,7 +1313,17 @@ 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.
{
// 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 "TOC":
field.TableOfContents = true;
break;
case "HYPERLINK":
{
Expand Down
6 changes: 6 additions & 0 deletions src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@
}

/// <summary>A caption of a picture, shape or text box: paragraphs <see cref="Gap"/> away from the object's side.</summary>
/// <param name="Width">Width of a caption on the left or right (HWPUNIT); one above or below is as wide as the object.</param>

Check warning on line 461 in src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs

View workflow job for this annotation

GitHub Actions / metadata (linux-x64)

XML comment has a param tag for 'Width', but there is no parameter by that name

Check warning on line 461 in src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs

View workflow job for this annotation

GitHub Actions / build

XML comment has a param tag for 'Width', but there is no parameter by that name

Check warning on line 461 in src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs

View workflow job for this annotation

GitHub Actions / portable / portable (ubuntu-24.04-arm, linux-arm64)

XML comment has a param tag for 'Width', but there is no parameter by that name

Check warning on line 461 in src/Filee.Engines/Hwp/Hwpx/HwpxModel.cs

View workflow job for this annotation

GitHub Actions / portable / portable (ubuntu-24.04, linux-x64)

XML comment has a param tag for 'Width', but there is no parameter by that name
internal sealed class HCaption(HCaptionSide side, int gap, int width)
{
public HCaptionSide Side { get; } = side;
Expand Down Expand Up @@ -577,6 +577,12 @@

/// <summary>The number's OWPML format (DIGIT, LATIN_CAPITAL, ROMAN_SMALL, ...); null for digits. Read for layout only.</summary>
public string? NumberFormat { get; init; }

/// <summary>
/// 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.
/// </summary>
public string? Stored { get; set; }
}

/// <summary>
Expand Down
4 changes: 4 additions & 0 deletions src/Filee.Engines/Hwp/Hwpx/HwpxWriter.Body.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ private void Inlines(IEnumerable<HInline> inlines, RunBuilder runs, string charP
case HBookmark bookmark:
runs.Control(charPrBase, $"<hp:ctrl><hp:bookmark name=\"{Escape(bookmark.Name)}\"/></hp:ctrl>");
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;
Expand Down
Loading
Loading