diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 42033ae..9c62c11 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -312,6 +312,37 @@ and writes the files; Filee's own renderer draws them with SkiaSharp (one drawin segments in total; beyond that the drawing is cut short with a warning in the log. Damaged or truncated files fail with "The file is not a valid DWG/DXF drawing or is damaged: …". +## HWPX reader + +`Hwp/Hwpx/HwpxReader*.cs` is the inverse of the writer: it reads an HWPX package into the same document model, so +HWPX (and HWP, which rhwp turns into HWPX first) can be written as DOCX, HTML or EPUB without Hancom Office or +LibreOffice. It reads files saved by 한글, by rhwp's `export-hwpx` and by Filee itself: + +- elements are matched by local name (any prefix or namespace version); `hp:switch` takes the `hp:case` for + HwpUnitChar / 2016 paragraphs, else `hp:default`, whose lengths 한글 doubles (as are margins and tab stops + outside a switch in older files); every `…IDRef` is resolved through `header.xml`; +- sections in spine order with page size (`landscape="NARROWLY"` turns the portrait sheet), margins, gutter, + columns (also changing mid-section), hidden first header/footer, start page (`startNum`, `hp:newNum`); +- headers and footers (both / odd / even pages) with page numbers and counts; an automatic page number + (`hp:pageNum`, e.g. "- 1 -" at the bottom centre) becomes a footer or header paragraph; +- paragraph shapes (alignment, indents with 한글's hanging-indent convention, spacing, line spacing kinds, tab stops, + keep options, page break before) and complete character shapes (fonts per language, size, bold/italic, + underline, strikeout, colour, shade, highlighter `hp:markpen`, super/subscript, letter spacing); +- outline paragraphs (개요) as headings, numbered with the section's outline numbering when it shows numbers; + numberings and bullets as lists; tabs, line breaks, non-breaking/fixed-width spaces, soft hyphens; tracked + deletions are left out; +- hyperlinks (HYPERLINK fields, also across paragraphs; `?name` = bookmark), bookmarks, other fields as their + text, footnotes and endnotes (without 한글's number), equations as their script text; +- tables with spans, column widths, row heights, header rows, borders and fills (`borderFill`), cell padding, + vertical alignment, captions and nested tables; a table splits the paragraph that holds it; +- pictures from `BinData` (inline, or floating with `hp:pos` offsets, alignment and wrapping), rectangles, + ellipses and lines with outline and solid / gradient fill, text boxes (`hp:drawText`), groups. + +Not kept (the model has no place for them; skipped and counted by the `Read(path, media, skipped)` overload): +OLE objects, charts, video, form controls, text art (its text is kept), arcs / polygons / curves without text, +master pages, memos, character ratio, relative size and offset, paragraph borders, picture cropping and rotation. +Password-protected (encrypted) HWPX and DRM-wrapped files stop with a clear error. + ## HWP ↔ HWPX rhwp converts between the two 한글 formats without Hancom Office (`export-hwpx` and `convert`). Anything → HWP goes diff --git a/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs b/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs index 55f0276..5c9a7fd 100644 --- a/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs +++ b/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs @@ -42,6 +42,8 @@ internal static class DocumentReaders ["potx"] = PptxReader.Read, ["ppsx"] = PptxReader.Read, ["pdf"] = PdfDocumentReader.Read, + // HWP comes here as HWPX, converted by rhwp first (the route planner adds that step). + ["hwpx"] = HwpxReader.Read, }; /// diff --git a/src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs b/src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs index 72e2067..e2932d9 100644 --- a/src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs +++ b/src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs @@ -24,7 +24,9 @@ public sealed class HwpxConverter : IConverter public int MaxParallelism => 0; public IReadOnlyList Edges => - DocumentReaders.Formats(_pandoc ?? PandocConverter.Locate() is not null).Select(from => new ConversionEdge(from, "hwpx")).ToList(); + DocumentReaders.Formats(_pandoc ?? PandocConverter.Locate() is not null) + .Where(from => from != "hwpx") // HWPX is read only to write other formats (DOCX, HTML, EPUB, ...) + .Select(from => new ConversionEdge(from, "hwpx")).ToList(); public EngineStatus GetStatus() { diff --git a/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Body.cs b/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Body.cs new file mode 100644 index 0000000..abe3d87 --- /dev/null +++ b/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Body.cs @@ -0,0 +1,586 @@ +// Paragraphs, runs and their content: text with tabs and line breaks, fields (hyperlinks), bookmarks, notes, page +// numbers, section controls and tables. 한글 keeps tables inside runs like characters; in the model a table is a +// block of its own, so the paragraph around it is split. + +using System.Text; +using System.Xml.Linq; + +namespace Filee.Engines.Hwp.Hwpx; + +internal sealed partial class HwpxReader +{ + /// Fields (fieldBegin … fieldEnd) open in the current paragraph list; hyperlinks may span paragraphs. + private List _fields = []; + + /// Highlighter colour between hp:markpenBegin and hp:markpenEnd. + private string? _markpen; + + /// Inside tracked deleted text (hp:deleteBegin … hp:deleteEnd), which the finished document does not show. + private bool _deleted; + + /// Numbering of outline paragraphs (개요) in the current section (secPr outlineShapeIDRef). + private string? _outlineNumberingId; + + private sealed record OpenField(string? Id, string? Target); + + /// Paragraphs and tables of a paragraph list (cell, header, footer, note, text box, caption). + private List ReadSubList(XElement? subList) + { + var blocks = new List(); + if (subList is null) + return blocks; + // Fields, highlighting and tracked deletions do not reach into or out of a nested paragraph list. + var (fields, markpen, deleted) = (_fields, _markpen, _deleted); + (_fields, _markpen, _deleted) = ([], null, false); + try + { + foreach (var element in Elements(subList)) + { + if (element.Name.LocalName == "p") + ReadParagraph(element, blocks, null); + else + Skip(element); + } + } + finally + { + (_fields, _markpen, _deleted) = (fields, markpen, deleted); + } + return blocks; + } + + // ───────────────────────── Paragraphs ───────────────────────── + + /// + /// Builds the block(s) of one hp:p: a table inside it ends the paragraph so far, becomes a block, and the text + /// after it continues in a new paragraph with the same format. + /// + private sealed class ParagraphBuilder + { + private readonly List _output; + private readonly List _fields; + private readonly Stack> _sinks = new(); + private readonly HParaFormat _format; + private readonly int _heading; + private readonly HListRef? _list; + private bool _afterTable; + + public ParagraphBuilder(List output, List fields, HParaFormat format, int heading, HListRef? list) + { + _output = output; + _fields = fields; + _format = format; + _heading = heading; + _list = list; + Current = NewParagraph(list); + } + + public HParagraph Current { get; private set; } + + /// Format of the last run, used for the paragraph mark (the height of an empty paragraph). + public HCharFormat MarkFormat { get; set; } + + private List Sink => _sinks.Count > 0 ? _sinks.Peek() : Current.Inlines; + + public void Add(HInline inline) => Sink.Add(inline); + + /// Adds text, joined to the previous text when it has the same format. + public void AddText(string text, HCharFormat format) + { + if (text.Length == 0) + return; + var sink = Sink; + if (sink.Count > 0 && sink[^1] is HText last && last.Format == format) + sink[^1] = new HText(last.Text + text, format); + else + sink.Add(new HText(text, format)); + } + + public void OpenLink(string target) + { + var link = new HLink(target); + Sink.Add(link); + _sinks.Push(link.Content); + } + + public void CloseLink() + { + if (_sinks.Count > 0) + _sinks.Pop(); + } + + public void Table(HTable table) + { + // Breaks and column changes have no place on a table, so they keep an empty paragraph in front of it. + if (HasContent || Current.PageBreakBefore || Current.ColumnBreakBefore || Current.ColumnsChange is not null) + Emit(); + _output.Add(table); + // The rest of a list item is not numbered again. + Current = NewParagraph(_list is null ? null : _list with { Numbered = false }); + _afterTable = true; + } + + public void Finish() + { + if (!_afterTable || HasContent) + Emit(); + } + + private bool HasContent => Current.Inlines.Any(i => i is not HLink { Content.Count: 0 }); + + private void Emit() + { + Current.MarkFormat = MarkFormat; + _output.Add(Current); + } + + private HParagraph NewParagraph(HListRef? list) + { + Current = new HParagraph { Format = _format, HeadingLevel = _heading, List = list }; + _sinks.Clear(); + // A hyperlink that started in an earlier paragraph (or before a table) continues here. + foreach (var field in _fields.Where(f => f.Target is not null)) + OpenLink(field.Target!); + return Current; + } + } + + private void ReadParagraph(XElement p, List output, SectionState? section) + { + var shape = ParaShapeOf(Attr(p, "paraPrIDRef")); + var (heading, list) = HeadingAndList(shape, Attr(p, "styleIDRef")); + var builder = new ParagraphBuilder(output, _fields, shape.Format, heading, list) { MarkFormat = CharFormat(null) }; + builder.Current.PageBreakBefore = Flag(p, "pageBreak") || shape.PageBreakBefore; + builder.Current.ColumnBreakBefore = Flag(p, "columnBreak"); + + foreach (var child in Elements(p)) + { + switch (child.Name.LocalName) + { + case "run": + Run(child, builder, section); + break; + case "linesegarray": + break; // layout cache, computed again by every reader + default: + Skip(child); + break; + } + } + builder.Finish(); + } + + /// + /// Heading level and list of a paragraph shape. Outline paragraphs (개요) are headings; when the section's outline + /// numbering shows a number for their level (1., 가., ...), they are numbered too. + /// + private (int Heading, HListRef? List) HeadingAndList(ParaShape shape, string? styleId) + { + var heading = 0; + HListRef? list = null; + switch (shape.HeadingType) + { + case "OUTLINE": + { + heading = Math.Min(shape.HeadingLevel + 1, 9); + var numbering = Numbering(shape.HeadingId is { Length: > 0 } id && id != "0" ? id : _outlineNumberingId); + if (numbering is not null && shape.HeadingLevel < numbering.Levels.Count && HasCounter(numbering.Levels[shape.HeadingLevel].Text)) + list = new HListRef(numbering, shape.HeadingLevel, Numbered: true); + break; + } + case "NUMBER" or "NUMBERING" or "BULLET": + { + var numbering = shape.HeadingType == "BULLET" ? Bullet(shape.HeadingId) : Numbering(shape.HeadingId); + if (numbering is { Levels.Count: > 0 }) + list = new HListRef(numbering, Math.Min(shape.HeadingLevel, numbering.Levels.Count - 1), Numbered: true); + break; + } + } + return (heading > 0 ? heading : StyleHeadingLevel(styleId), list); + } + + // ───────────────────────── Runs ───────────────────────── + + private void Run(XElement run, ParagraphBuilder builder, SectionState? section) + { + var format = CharFormat(Attr(run, "charPrIDRef")); + builder.MarkFormat = format; + foreach (var item in Elements(run)) + { + switch (item.Name.LocalName) + { + case "t": + Text(item, builder, format); + break; + case "secPr": + break; // read by ReadTopParagraph + case "ctrl": + foreach (var control in Elements(item)) + Control(control, builder, format, section); + break; + case "tbl": + builder.Table(Table(item)); + break; + case "colPr" or "header" or "footer" or "footNote" or "endNote" or "fieldBegin" or "fieldEnd" or "bookmark" or "autoNum" or "newNum" or "pageNum": + Control(item, builder, format, section); // controls outside hp:ctrl (other producers) + break; + default: + DrawingObject(item, builder, format); + break; + } + } + } + + /// hp:t: text mixed with tabs, line breaks, special spaces, highlighter and change-tracking marks. + private void Text(XElement t, ParagraphBuilder builder, HCharFormat format) + { + foreach (var node in t.Nodes()) + { + if (node is XText text) + { + var value = text.Value; + // 한글 never stores a line break as a character (hp:lineBreak does that), so line ends are only the + // indentation of pretty-printed files. + if (value.Contains('\n') || value.Contains('\r')) + { + if (string.IsNullOrWhiteSpace(value)) + continue; + value = value.Replace("\r", "", StringComparison.Ordinal).Replace("\n", "", StringComparison.Ordinal); + } + if (!_deleted) + builder.AddText(value, Highlighted(format)); + continue; + } + if (node is not XElement element) + continue; + switch (element.Name.LocalName) + { + case "tab": + if (!_deleted) + builder.Add(new HTab(Highlighted(format))); + break; + case "lineBreak" or "columnBreak": + if (!_deleted) + builder.Add(new HLineBreak(format)); + break; + case "nbSpace": + if (!_deleted) + builder.AddText(" ", Highlighted(format)); + break; + case "fwSpace": + if (!_deleted) + builder.AddText(" ", Highlighted(format)); // fixed-width space: as wide as a digit + break; + case "hyphen": + if (!_deleted) + builder.AddText("­", Highlighted(format)); // soft hyphen, shown only at a line end + break; + case "markpenBegin": + _markpen = Color(Attr(element, "color")) ?? "#FFFF00"; + break; + case "markpenEnd": + _markpen = null; + break; + case "deleteBegin": + _deleted = true; + break; + case "deleteEnd": + _deleted = false; + break; + case "insertBegin" or "insertEnd" or "titleMark": + break; // inserted text is shown as it is; title marks only feed a table of contents + default: + Skip(element); + break; + } + } + } + + private HCharFormat Highlighted(HCharFormat format) => _markpen is null ? format : format with { Shade = _markpen }; + + // ───────────────────────── Controls ───────────────────────── + + private void Control(XElement control, ParagraphBuilder builder, HCharFormat format, SectionState? section) + { + switch (control.Name.LocalName) + { + case "colPr" when section is not null: + if (section.ColumnsSet) + { + builder.Current.ColumnsChange = Columns(control); + } + else + { + section.Current.Columns = Columns(control); + section.ColumnsSet = true; + } + break; + case "header" or "footer" when section is not null: + HeaderFooter(control, section.Current); + break; + case "footNote" or "endNote": + builder.Add(Note(control, endnote: control.Name.LocalName == "endNote")); + break; + case "fieldBegin": + FieldBegin(control, builder); + break; + case "fieldEnd": + FieldEnd(control, builder); + break; + case "bookmark": + if (Attr(control, "name") is { Length: > 0 } name) + builder.Add(new HBookmark(name)); + break; + case "autoNum": + switch (Attr(control, "numType")) + { + case "PAGE": + builder.Add(new HField(HFieldKind.PageNumber, format)); + break; + case "TOTAL_PAGE": + builder.Add(new HField(HFieldKind.TotalPages, format)); + break; + case "FOOTNOTE" or "ENDNOTE": + break; // the note's own number, written again by every target format + default: + // Picture, table and equation numbers: keep the number shown when the file was saved. + builder.AddText(Attr(control, "num") ?? "", format); + break; + } + break; + case "newNum" when section is not null: + if (Attr(control, "numType") == "PAGE" && !section.StartNumberSet) + { + section.Current.StartPageNumber = Math.Max(1, Int(control, "num", 1)); + section.StartNumberSet = true; + } + break; + case "pageNum" when section is not null: + section.PageNumber = control; + break; + default: + Skip(control); // hidden comments, index marks, page hiding, page number control, ... + break; + } + } + + /// A header or footer; 한글 lets a later one replace it from its page on, the model keeps the first. + private void HeaderFooter(XElement control, HSection section) + { + var pages = Attr(control, "applyPageType") switch + { + "EVEN" => HPageType.Even, + "ODD" => HPageType.Odd, + _ => HPageType.Both, + }; + var target = control.Name.LocalName == "header" ? section.Headers : section.Footers; + if (target.Any(h => h.Pages == pages)) + { + Skip(control); + return; + } + var item = new HHeaderFooter(pages); + item.Blocks.AddRange(ReadSubList(Child(control, "subList"))); + target.Add(item); + } + + private HNote Note(XElement control, bool endnote) + { + var note = new HNote(endnote); + note.Blocks.AddRange(ReadSubList(Child(control, "subList"))); + // The note starts with its number (an autoNum, skipped) and a space. + if (note.Blocks.FirstOrDefault() is HParagraph first && first.Inlines.FirstOrDefault() is HText { Text: [' ', ..] } lead) + { + if (lead.Text.Length == 1) + first.Inlines.RemoveAt(0); + else + first.Inlines[0] = new HText(lead.Text[1..], lead.Format); + } + return note; + } + + // ───────────────────────── Fields ───────────────────────── + + private void FieldBegin(XElement control, ParagraphBuilder builder) + { + string? target = null; + switch (Attr(control, "type")) + { + case "HYPERLINK": + target = HyperlinkTarget(control); + break; + case "BOOKMARK": + if (Attr(control, "name") is { Length: > 0 } name) + builder.Add(new HBookmark(name)); + break; + } + // Other fields (click-here, date, file name, ...) keep their result text, which follows until fieldEnd. + _fields.Add(new OpenField(Attr(control, "id"), target)); + if (target is not null) + builder.OpenLink(target); + } + + private void FieldEnd(XElement control, ParagraphBuilder builder) + { + var id = Attr(control, "beginIDRef"); + var index = _fields.FindLastIndex(f => id is null || f.Id == id); + if (index < 0) + index = _fields.Count - 1; + if (index < 0) + return; + var field = _fields[index]; + _fields.RemoveAt(index); + if (field.Target is not null) + builder.CloseLink(); + } + + /// + /// The target of a HYPERLINK field, from its "Command" parameter: the target with ':', '?', ';' and '#' escaped + /// by a backslash, then ";kind;..." ("url;1;0;0;"). An unescaped '?' starts a bookmark name ("?name" = a + /// bookmark in this document, "file.hwp?name" one in another file). + /// + internal static string? HyperlinkTarget(XElement fieldBegin) + { + var parameters = Descendants(fieldBegin, "stringParam").ToList(); + string? Parameter(string name) => parameters.FirstOrDefault(p => Attr(p, "name") == name)?.Value; + + if (Parameter("Command") is { Length: > 0 } command) + { + var target = new StringBuilder(); + for (var i = 0; i < command.Length; i++) + { + var c = command[i]; + if (c == '\\' && i + 1 < command.Length) + { + target.Append(command[++i]); + continue; + } + if (c == ';') + break; + // Producers that do not escape keep a URL's query string ('?') as it is. + if (c == '?' && !target.ToString().Contains("://", StringComparison.Ordinal)) + c = '#'; + target.Append(c); + } + if (target.Length > 0 && target.ToString() != "#") + return target.ToString(); + } + return Parameter("Path") is { Length: > 0 } path ? path : null; + } + + // ───────────────────────── Tables ───────────────────────── + + private HTable Table(XElement tbl) + { + var table = new HTable { ColumnCount = Math.Max(0, Int(tbl, "colCnt")) }; + var pos = Child(tbl, "pos"); + if (pos is not null && !Flag(pos, "treatAsChar")) + { + table.Align = Attr(pos, "horzAlign") switch + { + "CENTER" => HAlign.Center, + "RIGHT" => HAlign.Right, + _ => HAlign.Left, + }; + if (table.Align == HAlign.Left && Attr(pos, "horzRelTo") is null or "COLUMN" or "PARA") + table.Indent = Math.Max(0, Int(pos, "horzOffset")); + } + if (Child(tbl, "inMargin") is { } inMargin) + table.CellMargin = new HInsets(Int(inMargin, "left"), Int(inMargin, "right"), Int(inMargin, "top"), Int(inMargin, "bottom")); + table.Borders = BorderFill(Attr(tbl, "borderFillIDRef"))?.Borders; + + var widths = new List<(int Column, int Span, int Width)>(); + var cellMargins = new HashSet(); + var headerRows = true; + foreach (var tr in Children(tbl, "tr")) + { + var row = new HRow(); + int? height = null; + var allHeader = true; + foreach (var tc in Children(tr, "tc")) + { + // Some producers wrap the cell properties in hp:tcPr. + XElement? Part(string name) => Child(tc, name) ?? Child(Child(tc, "tcPr"), name); + var address = Part("cellAddr"); + var span = Part("cellSpan"); + var size = Part("cellSz"); + var subList = Child(tc, "subList"); + var cell = new HCell + { + Column = address is null ? -1 : Math.Max(0, Int(address, "colAddr")), + ColSpan = Math.Max(1, Int(span, "colSpan", 1)), + RowSpan = Math.Max(1, Int(span, "rowSpan", 1)), + VerticalAlign = VerticalAlign(Attr(subList, "vertAlign")), + }; + if (BorderFill(Attr(tc, "borderFillIDRef")) is { } borderFill) + (cell.Borders, cell.Fill) = borderFill; + cell.Blocks.AddRange(ReadSubList(subList)); + row.Cells.Add(cell); + + if (cell.RowSpan == 1 && Int(size, "height") is > 0 and var h) + height = Math.Min(height ?? int.MaxValue, h); + if (cell.Column >= 0) + widths.Add((cell.Column, cell.ColSpan, Int(size, "width"))); + allHeader &= Flag(tc, "header"); + cellMargins.Add(Flag(tc, "hasMargin") && Part("cellMargin") is { } margin + ? new HInsets(Int(margin, "left"), Int(margin, "right"), Int(margin, "top"), Int(margin, "bottom")) + : null); + } + if (row.Cells.Count == 0) + continue; + row.Height = height; + // Header rows (제목 줄) are the leading rows whose cells are all marked as header cells. + headerRows &= allHeader; + row.Header = headerRows; + table.Rows.Add(row); + } + + // The model has one cell padding per table: cells with their own margins (hasMargin) win when they all agree. + if (cellMargins.Count == 1 && cellMargins.Single() is { } shared) + table.CellMargin = shared; + table.ColumnCount = Math.Max(table.ColumnCount, widths.Select(w => w.Column + w.Span).DefaultIfEmpty(1).Max()); + table.ColumnWidths = ColumnWidths(widths, table.ColumnCount); + if (Child(Child(tbl, "caption"), "subList") is { } caption) + table.Caption.AddRange(ReadSubList(caption)); + return table; + } + + /// Column widths from the cell widths: single cells first, then what spanning cells leave over. + private static int[]? ColumnWidths(List<(int Column, int Span, int Width)> cells, int columns) + { + if (columns <= 0) + return null; + var widths = new int?[columns]; + foreach (var (column, span, width) in cells) + { + if (span == 1 && column < columns && width > 0) + widths[column] ??= width; + } + for (var changed = true; changed;) + { + changed = false; + foreach (var (column, span, width) in cells.Where(c => c.Span > 1 && c.Width > 0)) + { + var range = Enumerable.Range(column, Math.Min(span, columns - column)).ToList(); + var unknown = range.Where(i => widths[i] is null).ToList(); + if (unknown.Count == 0) + continue; + var rest = width - range.Sum(i => widths[i] ?? 0); + foreach (var i in unknown) + widths[i] = Math.Max(1, rest / unknown.Count); + changed = true; + } + } + if (widths.All(w => w is null)) + return null; + var fallback = (int)widths.Where(w => w is not null).Average(w => w!.Value); + return widths.Select(w => w ?? fallback).ToArray(); + } + + private static HVerticalAlign VerticalAlign(string? value) => value switch + { + "CENTER" => HVerticalAlign.Center, + "BOTTOM" => HVerticalAlign.Bottom, + _ => HVerticalAlign.Top, + }; +} diff --git a/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Header.cs b/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Header.cs new file mode 100644 index 0000000..df8b6a0 --- /dev/null +++ b/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Header.cs @@ -0,0 +1,346 @@ +// header.xml: fonts, character and paragraph shapes, numberings and bullets, border/fill definitions, tab stops and +// styles. Every shape a paragraph or run refers to is complete (no inheritance), so each id maps to one format. +// +// Lengths in paragraph shapes and tab stops come in two scales: hp:case branches for HwpUnitChar-aware readers hold +// true HWPUNIT, while hp:default branches and shapes without a switch (older files) hold them doubled, as HwpxWriter +// and 한글 write them. + +using System.Globalization; +using System.Xml.Linq; + +namespace Filee.Engines.Hwp.Hwpx; + +internal sealed partial class HwpxReader +{ + private readonly Dictionary<(string Language, string Id), string> _fonts = []; + private readonly Dictionary _charPrs = new(StringComparer.Ordinal); + private readonly Dictionary _paraPrs = new(StringComparer.Ordinal); + private readonly Dictionary _borderFillElements = new(StringComparer.Ordinal); + private readonly Dictionary _tabPrs = new(StringComparer.Ordinal); + private readonly Dictionary _numberingElements = new(StringComparer.Ordinal); + private readonly Dictionary _bulletElements = new(StringComparer.Ordinal); + private readonly Dictionary _styles = new(StringComparer.Ordinal); + + private readonly Dictionary _charFormats = new(StringComparer.Ordinal); + private readonly Dictionary _paraShapes = new(StringComparer.Ordinal); + private readonly Dictionary _numberings = new(StringComparer.Ordinal); + private readonly Dictionary _borderFills = new(StringComparer.Ordinal); + + /// A paragraph shape: format plus the list or outline it belongs to. + /// OWPML heading type: NONE, OUTLINE, NUMBER or BULLET. + /// 0-based level of the list or outline. + private sealed record ParaShape(HParaFormat Format, string HeadingType, string? HeadingId, int HeadingLevel, bool PageBreakBefore); + + private void LoadHeader(XElement head) + { + foreach (var element in head.Descendants()) + { + var id = Attr(element, "id"); + var parent = element.Parent?.Name.LocalName; + switch (element.Name.LocalName) + { + case "font" when parent == "fontface" && id is not null: + _fonts[(Attr(element.Parent, "lang") ?? "", id)] = Attr(element, "face") ?? ""; + break; + case "charPr" when id is not null: + _charPrs.TryAdd(id, element); + break; + case "paraPr" when id is not null: + _paraPrs.TryAdd(id, element); + break; + case "borderFill" when id is not null: + _borderFillElements.TryAdd(id, element); + break; + case "tabPr" when id is not null: + _tabPrs.TryAdd(id, element); + break; + case "numbering" when parent == "numberings" && id is not null: + _numberingElements.TryAdd(id, element); + break; + case "bullet" when parent == "bullets" && id is not null: + _bulletElements.TryAdd(id, element); + break; + case "style" when id is not null: + _styles.TryAdd(id, element); + break; + } + } + } + + // ───────────────────────── Character shapes ───────────────────────── + + /// The complete character format of a charPr id (every member set, so the look survives other styles). + private HCharFormat CharFormat(string? id) + { + id ??= "0"; + if (_charFormats.TryGetValue(id, out var cached)) + return cached; + var charPr = _charPrs.GetValueOrDefault(id) ?? _charPrs.GetValueOrDefault("0"); + if (charPr is null) + return _charFormats[id] = default; + + var fontRef = Child(charPr, "fontRef"); + string? Face(string language) => + Attr(fontRef, language.ToLowerInvariant()) is { } fontId && _fonts.TryGetValue((language, fontId), out var face) && face.Length > 0 ? face : null; + + var strike = Attr(Child(charPr, "strikeout"), "shape"); + var format = new HCharFormat( + Bold: Child(charPr, "bold") is not null, + Italic: Child(charPr, "italic") is not null, + Underline: Attr(Child(charPr, "underline"), "type") == "BOTTOM", + // "3D" is 한글's placeholder for "no strikeout" in some versions, like NONE. + Strike: strike is not (null or "NONE" or "3D"), + Superscript: Child(charPr, "supscript") is not null, + Subscript: Child(charPr, "subscript") is not null, + Size: Int(charPr, "height", 1000), + Font: Face("LATIN"), + EastAsianFont: Face("HANGUL"), + Color: Color(Attr(charPr, "textColor")) ?? "#000000", + Shade: Color(Attr(charPr, "shadeColor")), + Spacing: Int(Child(charPr, "spacing"), "hangul")); + return _charFormats[id] = format; + } + + // ───────────────────────── Paragraph shapes ───────────────────────── + + private ParaShape ParaShapeOf(string? id) + { + id ??= "0"; + if (_paraShapes.TryGetValue(id, out var cached)) + return cached; + var paraPr = _paraPrs.GetValueOrDefault(id) ?? _paraPrs.GetValueOrDefault("0"); + if (paraPr is null) + return _paraShapes[id] = new ParaShape(default, "NONE", null, 0, false); + + var heading = Child(paraPr, "heading"); + var breaks = Child(paraPr, "breakSetting"); + var (intent, left, right, before, after, lineSpacing) = Spacing(paraPr); + var format = new HParaFormat( + Align: Attr(Child(paraPr, "align"), "horizontal") switch + { + "CENTER" => HAlign.Center, + "RIGHT" => HAlign.Right, + "JUSTIFY" => HAlign.Justify, + "DISTRIBUTE" or "DISTRIBUTE_SPACE" => HAlign.Distribute, + _ => HAlign.Left, + }, + // A negative "intent" (hanging indent) keeps the first line at "left" and moves the other lines right; + // the model, like Word, measures "left" to the other lines and puts the first line at left + firstLine. + Left: intent < 0 ? left - intent : left, + Right: right, + FirstLine: intent, + Before: before, + After: after, + LineSpacing: lineSpacing, + KeepWithNext: Flag(breaks, "keepWithNext"), + KeepLines: Flag(breaks, "keepLines"), + WidowOrphan: Flag(breaks, "widowOrphan"), + Tabs: TabStops(Attr(paraPr, "tabPrIDRef"))); + var shape = new ParaShape(format, Attr(heading, "type") ?? "NONE", Attr(heading, "idRef"), Math.Max(0, Int(heading, "level")), Flag(breaks, "pageBreakBefore")); + return _paraShapes[id] = shape; + } + + /// Margins and line spacing from the switch branch with exact values, else the doubled ones halved. + private static (int Intent, int Left, int Right, int Before, int After, HLineSpacing LineSpacing) Spacing(XElement paraPr) + { + XElement? margin = null, line = null; + bool marginExact = false, lineExact = false; + foreach (var child in paraPr.Elements()) + { + switch (child.Name.LocalName) + { + case "switch": + { + var (branch, exact) = Branch(child); + if (branch?.Elements().FirstOrDefault(e => e.Name.LocalName == "margin") is { } m) + (margin, marginExact) = (m, exact); + if (branch?.Elements().FirstOrDefault(e => e.Name.LocalName == "lineSpacing") is { } l) + (line, lineExact) = (l, exact); + break; + } + case "margin" when margin is null: + margin = child; + break; + case "lineSpacing" when line is null: + line = child; + break; + } + } + + int Length(string name, string attribute) + { + // children, or attributes on hh:margin in older files. + var element = margin?.Elements().FirstOrDefault(e => e.Name.LocalName == name); + var value = element is not null ? Int(element, "value") : Int(margin, attribute); + return marginExact ? value : (int)Math.Round(value / 2.0); + } + + var type = Attr(line, "type") ?? "PERCENT"; + var value = Int(line, "value", 160); + var length = lineExact ? value : (int)Math.Round(value / 2.0); + var lineSpacing = type switch + { + "FIXED" => new HLineSpacing(HLineSpacingKind.Fixed, length), + "AT_LEAST" or "MINIMUM" => new HLineSpacing(HLineSpacingKind.AtLeast, length), + // "Space between lines only": a 10 pt line plus the gap, as the model has no such kind. + "BETWEEN_LINES" or "SPACE_ONLY" or "SPACEONLY" => new HLineSpacing(HLineSpacingKind.AtLeast, 1000 + length), + _ => new HLineSpacing(HLineSpacingKind.Percent, value), + }; + return (Length("intent", "indent"), Length("left", "left"), Length("right", "right"), Length("prev", "prev"), Length("next", "next"), lineSpacing); + } + + // ───────────────────────── Tab stops ───────────────────────── + + private IReadOnlyList? TabStops(string? tabPrId) + { + if (tabPrId is null || !_tabPrs.TryGetValue(tabPrId, out var tabPr)) + return null; + var stops = new List(); + foreach (var child in tabPr.Elements()) + { + IEnumerable items; + bool exact; + if (child.Name.LocalName == "switch") + { + var (branch, branchExact) = Branch(child); + (items, exact) = (branch?.Elements() ?? [], branchExact); + } + else if (child.Name.LocalName == "tabItem") + { + // A tab item outside a switch is exact when it says so (unit="HWPUNIT"), else in the doubled scale. + (items, exact) = ([child], Attr(child, "unit") == "HWPUNIT"); + } + else + { + continue; + } + foreach (var item in items.Where(e => e.Name.LocalName == "tabItem")) + { + var position = Int(item, "pos"); + stops.Add(new HTabStop(exact ? position : position / 2, Attr(item, "type") switch + { + "RIGHT" => HTabKind.Right, + "CENTER" => HTabKind.Center, + "DECIMAL" => HTabKind.Decimal, + _ => HTabKind.Left, + }, Attr(item, "leader") ?? "NONE")); + } + } + return stops.Count == 0 ? null : stops; + } + + // ───────────────────────── Numbering and bullets ───────────────────────── + + /// List definition of a numbering (hh:numbering) id; each paraHead is one level ("^1." style text). + private HNumbering? Numbering(string? id) + { + if (id is null) + return null; + if (_numberings.TryGetValue("n" + id, out var cached)) + return cached; + HNumbering? numbering = null; + if (_numberingElements.TryGetValue(id, out var element)) + { + numbering = new HNumbering(); + var start = Int(element, "start", 1); + foreach (var head in Children(element, "paraHead").OrderBy(h => Int(h, "level", 1))) + { + var text = head.Value; + // A level without a counter (^n) is a symbol: Filee writes bullets that way. + var bullet = text.Length > 0 && !HasCounter(text); + numbering.Levels.Add(new HNumberingLevel(Attr(head, "numFormat") ?? "DIGIT", text, Int(head, "start", Math.Max(start, 1)), bullet)); + } + } + return _numberings["n" + id] = numbering; + } + + /// List definition of a bullet (hh:bullet) id: the same symbol on every level. + private HNumbering? Bullet(string? id) + { + if (id is null) + return null; + if (_numberings.TryGetValue("b" + id, out var cached)) + return cached; + HNumbering? numbering = null; + if (_bulletElements.TryGetValue(id, out var element)) + { + var symbol = Attr(element, "char") is { Length: > 0 } c && !Flag(element, "useImage") ? BulletSymbol(c) : "●"; + numbering = new HNumbering(); + for (var i = 0; i < 10; i++) + numbering.Levels.Add(new HNumberingLevel("DIGIT", symbol, 1, Bullet: true)); + } + return _numberings["b" + id] = numbering; + } + + private static bool HasCounter(string text) + { + for (var i = 0; i + 1 < text.Length; i++) + { + if (text[i] == '^' && char.IsDigit(text[i + 1])) + return true; + } + return false; + } + + /// Symbol-font bullets use private-use code points that other programs do not show. + private static string BulletSymbol(string text) => text[0] is >= '' and <= '' ? "●" : text; + + // ───────────────────────── Borders and fills ───────────────────────── + + /// Borders and background of a borderFill id; null when the id is unknown. + private (HBorders Borders, string? Fill)? BorderFill(string? id) + { + if (id is null) + return null; + if (_borderFills.TryGetValue(id, out var cached)) + return cached; + (HBorders, string?)? result = null; + if (_borderFillElements.TryGetValue(id, out var element)) + { + HBorder Edge(string name) => Border(Child(element, name)); + var brush = Child(element, "fillBrush"); + result = (new HBorders(Edge("leftBorder"), Edge("rightBorder"), Edge("topBorder"), Edge("bottomBorder")), + Color(Attr(Child(brush, "winBrush"), "faceColor")) + ?? Children(Child(brush, "gradation"), "color").Select(c => Color(Attr(c, "value"))).FirstOrDefault(c => c is not null)); + } + return _borderFills[id] = result; + } + + /// A cell or page border: line type, width like "0.12 mm" and colour. + private static HBorder Border(XElement? edge) + { + var style = LineStyle(Attr(edge, "type")); + if (edge is null || style == HBorderStyle.None) + return HBorder.None; + var width = Attr(edge, "width") ?? "0.12"; + var number = new string(width.TakeWhile(c => char.IsDigit(c) || c == '.').ToArray()); + return new HBorder(style, + double.TryParse(number, NumberStyles.Float, CultureInfo.InvariantCulture, out var mm) && mm > 0 ? mm : 0.12, + Color(Attr(edge, "color")) ?? "#000000"); + } + + /// OWPML line types mapped onto the few styles the model (and Word) share. + private static HBorderStyle LineStyle(string? type) => type switch + { + null or "NONE" => HBorderStyle.None, + "DASH" or "LONG_DASH" => HBorderStyle.Dash, + "DOT" or "CIRCLE" => HBorderStyle.Dot, + "DASH_DOT" or "DASH_DOT_DOT" => HBorderStyle.DashDot, + "DOUBLE_SLIM" or "SLIM_THICK" or "THICK_SLIM" or "SLIM_THICK_SLIM" or "DOUBLE_WAVE" => HBorderStyle.Double, + _ => HBorderStyle.Solid, + }; + + // ───────────────────────── Styles ───────────────────────── + + /// Heading level 1..9 of a style named "Heading n" (as Filee writes them), else 0. + private int StyleHeadingLevel(string? styleId) + { + if (styleId is null || !_styles.TryGetValue(styleId, out var style)) + return 0; + var name = Attr(style, "engName") ?? ""; + return name.StartsWith("Heading ", StringComparison.OrdinalIgnoreCase) && + int.TryParse(name.AsSpan(8), NumberStyles.Integer, CultureInfo.InvariantCulture, out var level) && level is >= 1 and <= 9 + ? level + : 0; + } +} diff --git a/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Objects.cs b/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Objects.cs new file mode 100644 index 0000000..fb7cd9d --- /dev/null +++ b/src/Filee.Engines/Hwp/Hwpx/HwpxReader.Objects.cs @@ -0,0 +1,266 @@ +// Objects inside runs: pictures (inline or floating), rectangles / ellipses / lines, text boxes (drawing objects with +// hp:drawText), groups (hp:container), equations and other objects that only carry text. Objects the model cannot +// hold (OLE, charts, video, form controls, free-form shapes without text) are skipped and counted. + +using System.Globalization; +using System.Xml.Linq; + +namespace Filee.Engines.Hwp.Hwpx; + +internal sealed partial class HwpxReader +{ + /// Drawing objects that may appear in a run or a group. + private static readonly HashSet ShapeNames = ["pic", "rect", "ellipse", "line", "arc", "polygon", "curve", "connectLine", "container"]; + + /// Size and position of an object: Anchor is null for objects laid out like a character. + private readonly record struct Placement(int Width, int Height, HAnchor? Anchor); + + private void DrawingObject(XElement element, ParagraphBuilder builder, HCharFormat format) + { + var name = element.Name.LocalName; + if (ShapeNames.Contains(name)) + { + foreach (var inline in Shapes(element, PlacementOf(element))) + builder.Add(inline); + return; + } + switch (name) + { + case "equation": + // The equation script (한글's equation language, e.g. "x over y") is the closest text form. + if (Child(element, "script")?.Value.Trim() is { Length: > 0 } script) + builder.AddText(script, format); + break; + case "textart": + if ((Attr(element, "text") ?? Child(element, "text")?.Value) is { Length: > 0 } art) + builder.AddText(art, format); + break; + case "compose": + // Overlapping characters (글자 겹치기): the characters one after the other. + if ((Attr(element, "composeText") ?? Child(element, "composeText")?.Value) is { Length: > 0 } composed) + builder.AddText(composed, format); + break; + case "dutmal": + // Ruby text (덧말): the main text; the small text above it has no place in the model. + if ((Attr(element, "mainText") ?? Child(element, "mainText")?.Value) is { Length: > 0 } main) + builder.AddText(main, format); + break; + default: + Skip(element); // ole, chart, video, form controls, ... + break; + } + } + + /// The model objects for one drawing object; groups give one object per member. + private IEnumerable Shapes(XElement element, Placement placement) + { + switch (element.Name.LocalName) + { + case "pic": + if (Picture(element, placement) is { } picture) + yield return picture; + else + Skip("pic"); + break; + case "rect": + yield return Box(element, HShapeKind.Rectangle, placement); + break; + case "ellipse": + yield return Box(element, HShapeKind.Ellipse, placement); + break; + case "line": + { + var start = Child(element, "startPt"); + var end = Child(element, "endPt"); + var points = start is not null && end is not null; + yield return new HShape(HShapeKind.Line) + { + // A horizontal or vertical line still has a size of one unit across in the file. + Width = points && Int(start, "x") == Int(end, "x") ? 0 : placement.Width, + Height = points && Int(start, "y") == Int(end, "y") ? 0 : placement.Height, + Anchor = placement.Anchor, + Line = LineShape(Child(element, "lineShape")), + // A line runs corner to corner of its box; the end points say which corners. + FlipHorizontal = Int(start, "x") > Int(end, "x"), + FlipVertical = Int(start, "y") > Int(end, "y"), + }; + break; + } + case "container": + foreach (var member in Group(element, placement)) + yield return member; + break; + default: + // Arcs, polygons, curves and connectors: only their text survives, in a box without outline. + if (Child(element, "drawText") is not null) + { + var box = (HTextBox)Box(element, HShapeKind.Rectangle, placement); + box.Line = HBorder.None; + box.Fill = null; + box.Gradient = null; + yield return box; + } + else + { + Skip(element); + } + break; + } + } + + private HImage? Picture(XElement pic, Placement placement) + { + var image = pic.Descendants().FirstOrDefault(e => e.Name.LocalName == "img" && e.Attribute("binaryItemIDRef") is not null); + return ExtractBinary(Attr(image, "binaryItemIDRef")) is { } path + ? new HImage(path) + { + Width = placement.Width > 0 ? placement.Width : null, + Height = placement.Height > 0 ? placement.Height : null, + Anchor = placement.Anchor, + } + : null; + } + + /// A rectangle or ellipse: a text box when it has text (hp:drawText), else a plain shape. + private HInline Box(XElement element, HShapeKind kind, Placement placement) + { + var line = LineShape(Child(element, "lineShape")); + var (fill, gradient) = FillBrush(Child(element, "fillBrush")); + var ratio = kind == HShapeKind.Rectangle ? Int(element, "ratio") : 0; + if (Child(element, "drawText") is not { } drawText) + { + return new HShape(kind) + { + Width = placement.Width, + Height = placement.Height, + Anchor = placement.Anchor, + Fill = fill, + Gradient = gradient, + Line = line, + CornerRatio = ratio, + }; + } + + var subList = Child(drawText, "subList"); + var margin = Child(drawText, "textMargin"); + var box = new HTextBox + { + Width = placement.Width, + Height = placement.Height, + Anchor = placement.Anchor, + Fill = fill, + Gradient = gradient, + Line = line, + Shape = kind, + CornerRatio = ratio, + VerticalAlign = VerticalAlign(Attr(subList, "vertAlign")), + }; + if (margin is not null) + box.Padding = new HInsets(Int(margin, "left"), Int(margin, "right"), Int(margin, "top"), Int(margin, "bottom")); + box.Blocks.AddRange(ReadSubList(subList)); + return box; + } + + /// + /// Members of a group (묶음 개체) become separate objects at their place inside the group. An inline group floats + /// over its paragraph instead, like the DOCX reader does with inline groups. + /// + private IEnumerable Group(XElement container, Placement placement) + { + var anchor = placement.Anchor ?? new HAnchor("COLUMN", "LEFT", 0, "PARA", "TOP", 0, "IN_FRONT_OF_TEXT", true); + foreach (var member in Elements(container)) + { + if (!ShapeNames.Contains(member.Name.LocalName)) + continue; // the group's own size, position and rendering properties + var offset = Child(member, "offset"); + var (x, y) = (Int(offset, "x"), Int(offset, "y")); + var (width, height) = MemberSize(member); + var memberAnchor = anchor with + { + HorizontalOffset = anchor.HorizontalOffset + AlignedOffset(anchor.HorizontalAlign, x, width, placement.Width), + VerticalOffset = anchor.VerticalOffset + AlignedOffset(anchor.VerticalAlign, y, height, placement.Height), + AllowOverlap = true, + }; + foreach (var inline in Shapes(member, new Placement(width, height, memberAnchor))) + yield return inline; + } + } + + /// Keeps a member's distance from the group's centre or far edge when the group is aligned, not placed. + private static int AlignedOffset(string align, int position, int size, int groupSize) => align switch + { + "CENTER" => position + size / 2 - groupSize / 2, + "RIGHT" or "BOTTOM" => position + size - groupSize, + _ => position, + }; + + /// Size of a group member: its current size, else its original size scaled by its rendering matrix. + private static (int Width, int Height) MemberSize(XElement member) + { + var current = Child(member, "curSz"); + if (Int(current, "width") > 0 && Int(current, "height") > 0) + return (Int(current, "width"), Int(current, "height")); + var original = Child(member, "orgSz"); + var scale = Child(Child(member, "renderingInfo"), "scaMatrix"); + return ((int)Math.Round(Int(original, "width") * Double(scale, "e1", 1)), (int)Math.Round(Int(original, "height") * Double(scale, "e5", 1))); + } + + private static Placement PlacementOf(XElement element) + { + var size = Child(element, "sz"); + var (width, height) = (Int(size, "width"), Int(size, "height")); + if (width <= 0 || height <= 0) + (width, height) = MemberSize(element); + return new Placement(width, height, Anchor(element)); + } + + /// Position of a floating object (hp:pos); null when it is laid out like a character (treatAsChar). + private static HAnchor? Anchor(XElement element) + { + var pos = Child(element, "pos"); + if (pos is null || Flag(pos, "treatAsChar")) + return null; + return new HAnchor( + HorizontalRelativeTo: Attr(pos, "horzRelTo") ?? "COLUMN", + HorizontalAlign: Attr(pos, "horzAlign") ?? "LEFT", + HorizontalOffset: Int(pos, "horzOffset"), + VerticalRelativeTo: Attr(pos, "vertRelTo") ?? "PARA", + VerticalAlign: Attr(pos, "vertAlign") ?? "TOP", + VerticalOffset: Int(pos, "vertOffset"), + Wrap: Attr(element, "textWrap") switch + { + "SQUARE" or "TIGHT" or "THROUGH" => "SQUARE", + "BEHIND_TEXT" => "BEHIND_TEXT", + "IN_FRONT_OF_TEXT" => "IN_FRONT_OF_TEXT", + _ => "TOP_AND_BOTTOM", + }, + AllowOverlap: Flag(pos, "allowOverlap")); + } + + /// Outline of a drawing object; its width is in HWPUNIT (a width of 0 is 한글's thinnest line). + private static HBorder LineShape(XElement? lineShape) + { + var style = LineStyle(Attr(lineShape, "style")); + if (lineShape is null || style == HBorderStyle.None) + return HBorder.None; + var width = Int(lineShape, "width"); + return new HBorder(style, width > 0 ? width / HwpxUnits.PerMm : 0.1, Color(Attr(lineShape, "color")) ?? "#000000"); + } + + /// A solid colour (hc:winBrush) or linear gradient (hc:gradation); picture fills are not kept. + private static (string? Fill, HGradient? Gradient) FillBrush(XElement? fillBrush) + { + if (Child(fillBrush, "gradation") is { } gradation) + { + var colors = Children(gradation, "color").Select(c => Color(Attr(c, "value"))).OfType().ToList(); + if (colors.Count >= 2) + return (null, new HGradient(colors, Int(gradation, "angle"))); + if (colors.Count == 1) + return (colors[0], null); + } + return (Color(Attr(Child(fillBrush, "winBrush"), "faceColor")), null); + } + + private static double Double(XElement? element, string name, double fallback) => + double.TryParse(Attr(element, name), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && value != 0 ? value : fallback; +} diff --git a/src/Filee.Engines/Hwp/Hwpx/HwpxReader.cs b/src/Filee.Engines/Hwp/Hwpx/HwpxReader.cs new file mode 100644 index 0000000..e6b1478 --- /dev/null +++ b/src/Filee.Engines/Hwp/Hwpx/HwpxReader.cs @@ -0,0 +1,446 @@ +// Reads HWPX (한글 / OWPML, KS X 6101) packages into the HDocument model — the inverse of HwpxWriter — so HWP and +// HWPX can become DOCX, HTML or EPUB without Hancom Office or LibreOffice (HWP is turned into HWPX by rhwp first). +// +// Files come from 한글 itself, from rhwp's export-hwpx and from Filee's own writer, which differ in details. The +// reader therefore matches elements by local name (any prefix, any namespace version), resolves hp:switch blocks and +// looks every idRef up in header.xml. Split over partial files: +// * HwpxReader.cs package: container, manifest and spine, pictures (BinData), section setup, XML helpers; +// * HwpxReader.Header.cs header.xml: fonts, character and paragraph shapes, numberings, bullets, borders, tab stops; +// * HwpxReader.Body.cs paragraphs, runs, text, fields (hyperlinks), notes, bookmarks and tables; +// * HwpxReader.Objects.cs pictures, drawing objects, text boxes, groups and equations. + +using System.Globalization; +using System.IO.Compression; +using System.Xml; +using System.Xml.Linq; + +namespace Filee.Engines.Hwp.Hwpx; + +/// Reads an .hwpx file into the HWPX document model. +internal sealed partial class HwpxReader +{ + /// hp:case branches written for readers that know these namespaces hold the exact values. + private const string HwpUnitCharNamespace = "HwpUnitChar"; + private const string Paragraph2016Namespace = "http://www.hancom.co.kr/hwpml/2016/paragraph"; + + private readonly ZipArchive _zip; + private readonly string _mediaFolder; + + /// Manifest item id → zip entry of embedded binaries (pictures). + private readonly Dictionary _binaryItems = new(StringComparer.Ordinal); + + /// Binary item id → extracted file (null when the item is missing). + private readonly Dictionary _media = new(StringComparer.Ordinal); + + /// Elements the model cannot hold, by local name, with the number of times they were skipped. + private readonly Dictionary _skipped = new(StringComparer.Ordinal); + + private HwpxReader(ZipArchive zip, string mediaFolder) + { + _zip = zip; + _mediaFolder = mediaFolder; + } + + // ───────────────────────── Entry points ───────────────────────── + + /// Reads ; embedded pictures are extracted into . + public static HDocument Read(string path, string mediaFolder) => Read(path, mediaFolder, null); + + /// Receives the elements that were skipped because the model has no equivalent (local name → count). + public static HDocument Read(string path, string mediaFolder, IDictionary? skipped) + { + ZipArchive zip; + try + { + zip = ZipFile.OpenRead(path); + } + catch (InvalidDataException) + { + throw NotAPackage(path); + } + + using (zip) + { + var reader = new HwpxReader(zip, mediaFolder); + var document = reader.ReadDocument(); + if (skipped is not null) + { + foreach (var (name, count) in reader._skipped) + skipped[name] = count; + } + return document; + } + } + + /// A clear message for files that are not a zip package: HWP 5.0 binaries and DRM-wrapped files. + private static InvalidDataException NotAPackage(string path) + { + var header = new byte[8]; + using (var stream = File.OpenRead(path)) + _ = stream.Read(header); + return header is [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] + ? new InvalidDataException("The file is an HWP 5.0 (binary) document, not HWPX. Rename it to .hwp and convert it again.") + : new InvalidDataException("The file is not an HWPX package. It may be damaged or protected by document security (DRM) software."); + } + + // ───────────────────────── Package ───────────────────────── + + private HDocument ReadDocument() + { + RejectEncrypted(); + + var packagePath = RootFile(); + var package = LoadXml(packagePath, required: false); + var folder = packagePath.Contains('/') ? packagePath[..(packagePath.LastIndexOf('/') + 1)] : ""; + + // Manifest items: id → zip path. Hrefs are relative to the package root in files from 한글, but resolve them + // against the manifest's folder too for other producers. + var items = new Dictionary(StringComparer.Ordinal); + foreach (var item in Descendants(package?.Root, "item")) + { + if (Attr(item, "id") is not { Length: > 0 } id || Attr(item, "href") is not { Length: > 0 } href) + continue; + var entry = EntryPath(href, folder); + if (entry is null) + continue; + items[id] = entry; + if (entry.StartsWith("BinData/", StringComparison.OrdinalIgnoreCase) && Attr(item, "isEmbeded") is not "0") + _binaryItems[id] = entry; + } + + var header = LoadXml(items.Values.FirstOrDefault(p => p.EndsWith("header.xml", StringComparison.OrdinalIgnoreCase)) ?? "Contents/header.xml", required: true)!; + LoadHeader(header.Root!); + + var document = new HDocument + { + Title = Descendants(package?.Root, "title").FirstOrDefault()?.Value is { } title && !string.IsNullOrWhiteSpace(title) ? title.Trim() : null, + }; + foreach (var sectionPath in SectionPaths(package, items)) + { + var root = LoadXml(sectionPath, required: true)!.Root!; + ReadSection(root, document); + } + if (document.Sections.Count == 0) + throw new InvalidDataException("The HWPX file has no sections (Contents/section0.xml is missing)."); + return document; + } + + /// Password-protected HWPX files keep their parts encrypted, which is declared in META-INF/manifest.xml. + private void RejectEncrypted() + { + var manifest = LoadXml("META-INF/manifest.xml", required: false); + if (Descendants(manifest?.Root, "encryption-data").Any()) + throw new InvalidDataException("The HWPX file is password-protected. Open it in 한글 (Hangul), remove the password, save it and convert it again."); + } + + /// The package document (content.hpf) named in META-INF/container.xml. + private string RootFile() + { + var container = LoadXml("META-INF/container.xml", required: false); + var rootFiles = Descendants(container?.Root, "rootfile").ToList(); + var hpf = rootFiles.FirstOrDefault(r => Attr(r, "media-type") == "application/hwpml-package+xml") + ?? rootFiles.FirstOrDefault(r => Attr(r, "full-path")?.EndsWith(".hpf", StringComparison.OrdinalIgnoreCase) == true); + return Attr(hpf, "full-path") ?? "Contents/content.hpf"; + } + + /// Section parts in reading order: the spine, else Contents/sectionN.xml by number. + private List SectionPaths(XDocument? package, Dictionary items) + { + static bool IsSection(string path) => + Path.GetFileName(path) is var name && name.StartsWith("section", StringComparison.OrdinalIgnoreCase) && + name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase); + + var result = new List(); + foreach (var itemRef in Descendants(package?.Root, "itemref")) + { + if (items.TryGetValue(Attr(itemRef, "idref") ?? "", out var path) && IsSection(path) && !result.Contains(path)) + result.Add(path); + } + if (result.Count > 0) + return result; + + return _zip.Entries + .Where(e => e.FullName.StartsWith("Contents/", StringComparison.OrdinalIgnoreCase) && IsSection(e.FullName)) + .OrderBy(e => int.TryParse(Path.GetFileNameWithoutExtension(e.Name).AsSpan(7), NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : int.MaxValue) + .Select(e => e.FullName) + .ToList(); + } + + private string? EntryPath(string href, string folder) + { + var path = Uri.UnescapeDataString(href).Replace('\\', '/').TrimStart('/'); + if (_zip.GetEntry(path) is not null) + return path; + return folder.Length > 0 && _zip.GetEntry(folder + path) is not null ? folder + path : path; + } + + private XDocument? LoadXml(string path, bool required) + { + var entry = _zip.GetEntry(path); + if (entry is null) + { + return required + ? throw new InvalidDataException($"The HWPX file is incomplete: {path} is missing.") + : null; + } + try + { + using var stream = entry.Open(); + // Whitespace matters: is a real space. + return XDocument.Load(stream, LoadOptions.PreserveWhitespace); + } + catch (XmlException ex) + { + // Encrypted (distribution / DRM) documents have binary parts where XML is expected. + throw new InvalidDataException($"The HWPX file cannot be read ({path} is not valid XML). It may be encrypted or damaged. {ex.Message}", ex); + } + } + + /// Extracts a BinData item (by manifest id) into the media folder once; null when it is missing. + private string? ExtractBinary(string? itemId) + { + if (string.IsNullOrEmpty(itemId)) + return null; + if (_media.TryGetValue(itemId, out var cached)) + return cached; + + // Without a manifest entry, 한글's naming (BinData/.) still finds the file. + var entryPath = _binaryItems.GetValueOrDefault(itemId) + ?? _zip.Entries.FirstOrDefault(e => e.FullName.StartsWith("BinData/", StringComparison.OrdinalIgnoreCase) && + string.Equals(Path.GetFileNameWithoutExtension(e.Name), itemId, StringComparison.OrdinalIgnoreCase))?.FullName; + string? path = null; + if (entryPath is not null && _zip.GetEntry(entryPath) is { } entry) + { + Directory.CreateDirectory(_mediaFolder); + path = Path.Combine(_mediaFolder, $"hwpx-media-{_media.Count + 1}{Path.GetExtension(entry.Name).ToLowerInvariant()}"); + using var source = entry.Open(); + using var target = File.Create(path); + source.CopyTo(target); + } + _media[itemId] = path; + return path; + } + + // ───────────────────────── Sections ───────────────────────── + + /// + /// State of the section being read. A secPr starts a 한글 section (구역); its first colPr gives the columns and + /// later ones change them mid-page. Header, footer and page number controls may sit in any body paragraph. + /// + private sealed class SectionState(HDocument document) + { + public HSection? Section; + public bool ColumnsSet; + public bool StartNumberSet; + public XElement? PageNumber; + + public HSection Current => Section ?? Begin(null); + + public HSection Begin(HSection? section) + { + Finish(); + Section = section ?? new HSection(); + ColumnsSet = false; + StartNumberSet = Section.StartPageNumber is not null; + PageNumber = null; + document.Sections.Add(Section); + return Section; + } + + /// Turns an automatic page number (hp:pageNum, "쪽 번호 매기기") into a header or footer paragraph. + public void Finish() + { + if (Section is null || PageNumber is not { } control) + return; + PageNumber = null; + var position = Attr(control, "pos") ?? "BOTTOM_CENTER"; + if (position == "NONE") + return; + + var side = Attr(control, "sideChar") is { Length: > 0 } s && s != "\0" ? s : null; + var paragraph = new HParagraph + { + Format = new HParaFormat(Align: position switch + { + _ when position.EndsWith("LEFT", StringComparison.Ordinal) || position.StartsWith("INSIDE", StringComparison.Ordinal) => HAlign.Left, + _ when position.EndsWith("RIGHT", StringComparison.Ordinal) || position.StartsWith("OUTSIDE", StringComparison.Ordinal) => HAlign.Right, + _ => HAlign.Center, + }), + }; + if (side is not null) + paragraph.Inlines.Add(new HText(side + " ", default)); + paragraph.Inlines.Add(new HField(HFieldKind.PageNumber, default)); + if (side is not null) + paragraph.Inlines.Add(new HText(" " + side, default)); + + var top = position.Contains("TOP", StringComparison.Ordinal); + var target = top ? Section.Headers : Section.Footers; + if (target.Count == 0) + target.Add(new HHeaderFooter(HPageType.Both)); + foreach (var item in target) + item.Blocks.Add(paragraph); + } + } + + private void ReadSection(XElement root, HDocument document) + { + var state = new SectionState(document); + foreach (var element in Elements(root)) + { + if (element.Name.LocalName == "p") + ReadTopParagraph(element, state); + else + Skip(element); + } + if (state.Section is null) + state.Begin(null); // a section part without paragraphs is still a section + state.Finish(); + } + + /// A body paragraph: a secPr in it starts a new section (normally only in a part's first paragraph). + private void ReadTopParagraph(XElement p, SectionState state) + { + var secPr = Elements(p).Where(e => e.Name.LocalName == "run").SelectMany(Elements).FirstOrDefault(e => e.Name.LocalName == "secPr"); + if (secPr is not null) + { + state.Begin(Section(secPr)); + _outlineNumberingId = Attr(secPr, "outlineShapeIDRef"); + } + ReadParagraph(p, state.Current.Blocks, state); + } + + private static HSection Section(XElement secPr) + { + var pagePr = Child(secPr, "pagePr"); + var margin = Child(pagePr, "margin"); + var width = Int(pagePr, "width", 59530); + var height = Int(pagePr, "height", 84190); + // Width and height describe the portrait sheet; "NARROWLY" turns it (landscape). + if (Attr(pagePr, "landscape") == "NARROWLY") + (width, height) = (Math.Max(width, height), Math.Min(width, height)); + + var visibility = Child(secPr, "visibility"); + var start = Int(Child(secPr, "startNum"), "page"); + return new HSection + { + Page = new HPage(width, height, + Left: Int(margin, "left", 7200), Right: Int(margin, "right", 7200), + Top: Int(margin, "top", 4255), Bottom: Int(margin, "bottom", 4960), + Header: Int(margin, "header", 4250), Footer: Int(margin, "footer", 2240), + Gutter: Int(margin, "gutter")), + HideFirstHeader = Flag(visibility, "hideFirstHeader"), + HideFirstFooter = Flag(visibility, "hideFirstFooter"), + StartPageNumber = start > 0 ? start : null, + }; + } + + private static HColumns Columns(XElement colPr) + { + var count = Math.Max(1, Int(colPr, "colCount", 1)); + if (count == 1) + return HColumns.Single; + // Columns of different widths (sameSz="0") list each width and gap; the model keeps one gap. + var gap = Flag(colPr, "sameSz", true) ? Int(colPr, "sameGap") : Int(Child(colPr, "colSz"), "gap", Int(colPr, "sameGap")); + var line = Child(colPr, "colLine"); + return new HColumns(count, gap, line is not null && Attr(line, "type") is not (null or "NONE")); + } + + // ───────────────────────── XML helpers ───────────────────────── + + /// Child elements with hp:switch blocks replaced by the branch this reader understands. + private static IEnumerable Elements(XElement? parent) + { + if (parent is null) + yield break; + foreach (var child in parent.Elements()) + { + if (child.Name.LocalName == "switch") + { + foreach (var inner in Elements(Branch(child).Branch)) + yield return inner; + } + else + { + yield return child; + } + } + } + + /// + /// The hp:case whose required namespace is known (exact HWPUNIT values, 2016 paragraph features), else hp:default. + /// Exact is false for hp:default, where 한글 doubles lengths for older readers. + /// + private static (XElement? Branch, bool Exact) Branch(XElement switchElement) + { + foreach (var branch in switchElement.Elements()) + { + if (branch.Name.LocalName != "case") + continue; + var required = branch.Attributes().FirstOrDefault(a => a.Name.LocalName == "required-namespace")?.Value ?? ""; + if (required.Contains(HwpUnitCharNamespace, StringComparison.Ordinal) || required == Paragraph2016Namespace) + return (branch, true); + } + return (switchElement.Elements().FirstOrDefault(e => e.Name.LocalName == "default"), false); + } + + /// The first child with this local name, looking into hp:switch blocks (a loop: it runs for every property). + private static XElement? Child(XElement? parent, string name) + { + if (parent is null) + return null; + foreach (var child in parent.Elements()) + { + var local = child.Name.LocalName; + if (local == name) + return child; + if (local == "switch" && Child(Branch(child).Branch, name) is { } inner) + return inner; + } + return null; + } + + private static IEnumerable Children(XElement? parent, string name) => Elements(parent).Where(e => e.Name.LocalName == name); + + private static IEnumerable Descendants(XElement? root, string name) => + root?.Descendants().Where(e => e.Name.LocalName == name) ?? []; + + private static string? Attr(XElement? element, string name) => (string?)element?.Attribute(name); + + /// + /// An integer attribute. Offsets are unsigned 32-bit values in the file, so negative positions written by some + /// producers arrive as numbers above 2³¹ and are wrapped back. + /// + private static int Int(XElement? element, string name, int fallback = 0) + { + if (!long.TryParse(Attr(element, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + { + return double.TryParse(Attr(element, name), NumberStyles.Float, CultureInfo.InvariantCulture, out var real) + ? (int)Math.Clamp(Math.Round(real), int.MinValue, int.MaxValue) + : fallback; + } + if (value is > int.MaxValue and <= uint.MaxValue) + value -= 1L << 32; + return (int)Math.Clamp(value, int.MinValue, int.MaxValue); + } + + private static bool Flag(XElement? element, string name, bool fallback = false) => Attr(element, name) switch + { + null => fallback, + "1" or "true" => true, + _ => false, + }; + + /// "#RRGGBB" in upper case; null for "none" or unknown values. 한글 may write "#AARRGGBB". + private static string? Color(string? value) + { + if (value is null || !value.StartsWith('#')) + return null; + var hex = value.Length == 9 ? value[3..] : value[1..]; + return hex.Length == 6 && int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _) ? "#" + hex.ToUpperInvariant() : null; + } + + /// Counts an element the model cannot hold (see ). + private void Skip(XElement element) => Skip(element.Name.LocalName); + + private void Skip(string name) => _skipped[name] = _skipped.GetValueOrDefault(name) + 1; +} diff --git a/tests/Filee.Engines.Tests/DocxBuilder.cs b/tests/Filee.Engines.Tests/DocxBuilder.cs index b2eab2a..a17c6ab 100644 --- a/tests/Filee.Engines.Tests/DocxBuilder.cs +++ b/tests/Filee.Engines.Tests/DocxBuilder.cs @@ -74,6 +74,10 @@ public string Hyperlink(string url) => public void Footnotes(string notes) => Part("footnotes", $"{notes}"); + /// Endnotes (w:endnote elements with ids ≥ 1; the separators are added). + public void Endnotes(string notes) => + Part("endnotes", $"{notes}"); + public string Save(string path) { if (Numbering is not null) @@ -135,6 +139,7 @@ private string Relationship(string type, string target, bool external = false) "numbering.xml" => "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml", "settings.xml" => "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml", "footnotes.xml" => "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", + "endnotes.xml" => "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml", _ when part.StartsWith("header", StringComparison.Ordinal) => "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml", _ => "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml", }; diff --git a/tests/Filee.Engines.Tests/HDocumentAssert.cs b/tests/Filee.Engines.Tests/HDocumentAssert.cs new file mode 100644 index 0000000..d673332 --- /dev/null +++ b/tests/Filee.Engines.Tests/HDocumentAssert.cs @@ -0,0 +1,510 @@ +// Structural checks for the HWPX document model: a canonical text dump (for exact comparisons such as +// read → write → read) and a tolerant comparison of a source model with the model read back from the HWPX it became. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Filee.Engines.Hwp.Hwpx; + +namespace Filee.Engines.Tests; + +internal static class HDocumentAssert +{ + // ───────────────────────── Canonical dump ───────────────────────── + + /// Every member of every block, one line per item; pictures by content hash, lists by first appearance. + /// Include layout caches (table row heights), which the writer estimates. + public static string Dump(HDocument document, bool layout = true) + { + var dump = new Dumper(layout); + dump.Line(0, $"document title={document.Title}"); + foreach (var section in document.Sections) + { + dump.Line(0, $"section page={section.Page} columns={section.Columns} hide={section.HideFirstHeader}/{section.HideFirstFooter} start={section.StartPageNumber}"); + foreach (var header in section.Headers) + { + dump.Line(1, $"header {header.Pages}"); + dump.Blocks(2, header.Blocks); + } + foreach (var footer in section.Footers) + { + dump.Line(1, $"footer {footer.Pages}"); + dump.Blocks(2, footer.Blocks); + } + dump.Blocks(1, section.Blocks); + } + return dump.ToString(); + } + + private sealed class Dumper(bool layout) + { + private readonly StringBuilder _text = new(); + private readonly Dictionary _numberings = []; + + public void Line(int depth, string text) => _text.Append(' ', depth * 2).Append(text).Append('\n'); + + public override string ToString() => _text.ToString(); + + public void Blocks(int depth, IEnumerable blocks) + { + foreach (var block in blocks) + { + switch (block) + { + case HParagraph p: + Line(depth, $"P {Para(p.Format)} mark={Char(p.MarkFormat)} heading={p.HeadingLevel} list={List(p.List)} break={p.PageBreakBefore}/{p.ColumnBreakBefore} columns={p.ColumnsChange}"); + Inlines(depth + 1, p.Inlines); + break; + case HTable t: + Line(depth, $"TABLE count={t.ColumnCount} widths={Join(t.ColumnWidths)} relative={Join(t.RelativeWidths)} align={t.Align} indent={t.Indent} margin={t.CellMargin} borders={Borders(t.Borders)}"); + foreach (var row in t.Rows) + { + Line(depth + 1, $"ROW height={(layout ? row.Height : null)} header={row.Header}"); + foreach (var cell in row.Cells) + { + Line(depth + 2, $"CELL col={cell.Column} span={cell.RowSpan}x{cell.ColSpan} valign={cell.VerticalAlign} fill={cell.Fill} borders={Borders(cell.Borders)}"); + Blocks(depth + 3, cell.Blocks); + } + } + if (t.Caption.Count > 0) + { + Line(depth + 1, "CAPTION"); + Blocks(depth + 2, t.Caption); + } + break; + } + } + } + + private void Inlines(int depth, IEnumerable inlines) + { + foreach (var inline in inlines) + { + switch (inline) + { + case HText text: + Line(depth, $"T \"{text.Text}\" {Char(text.Format)}"); + break; + case HTab tab: + Line(depth, $"TAB {Char(tab.Format)}"); + break; + case HLineBreak lineBreak: + Line(depth, $"BR {Char(lineBreak.Format)}"); + break; + case HLink link: + Line(depth, $"LINK {link.Target}"); + Inlines(depth + 1, link.Content); + break; + case HBookmark bookmark: + Line(depth, $"BOOKMARK {bookmark.Name}"); + break; + case HNote note: + Line(depth, $"NOTE endnote={note.Endnote}"); + Blocks(depth + 1, note.Blocks); + break; + case HField field: + Line(depth, $"FIELD {field.Kind} {Char(field.Format)}"); + break; + case HImage image: + Line(depth, $"IMAGE {Hash(image.Path)} {image.Width}x{image.Height} anchor={image.Anchor}"); + break; + case HTextBox box: + Line(depth, $"TEXTBOX {box.Width}x{box.Height} anchor={box.Anchor} fill={box.Fill} gradient={Gradient(box.Gradient)} line={Border(box.Line)} shape={box.Shape} ratio={box.CornerRatio} padding={box.Padding} valign={box.VerticalAlign}"); + Blocks(depth + 1, box.Blocks); + break; + case HShape shape: + Line(depth, $"SHAPE {shape.Kind} {shape.Width}x{shape.Height} anchor={shape.Anchor} fill={shape.Fill} gradient={Gradient(shape.Gradient)} line={Border(shape.Line)} ratio={shape.CornerRatio} flip={shape.FlipHorizontal}/{shape.FlipVertical}"); + break; + } + } + } + + private string List(HListRef? list) + { + if (list is null) + return "none"; + if (!_numberings.TryGetValue(list.Numbering, out var id)) + { + id = _numberings[list.Numbering] = _numberings.Count + 1; + return $"#{id}[{string.Join("|", list.Numbering.Levels.Select(l => $"{l.Format},{l.Text},{l.Start},{l.Bullet}"))}] L{list.Level} {list.Numbered}"; + } + return $"#{id} L{list.Level} {list.Numbered}"; + } + } + + private static string Para(HParaFormat f) => + $"[{f.Align} {f.Left}/{f.Right}/{f.FirstLine} {f.Before}/{f.After} {f.LineSpacing} keep={f.KeepWithNext}/{f.KeepLines}/{f.WidowOrphan} tabs={(f.Tabs is null ? "-" : string.Join(";", f.Tabs))}]"; + + private static string Char(HCharFormat f) => f.ToString().Replace("HCharFormat ", "", StringComparison.Ordinal); + + private static string Borders(HBorders? b) => b is { } x ? $"{Border(x.Left)} {Border(x.Right)} {Border(x.Top)} {Border(x.Bottom)}" : "-"; + + /// Widths as 한글 stores them (snapped), so equal borders compare equal after a round trip. + private static string Border(HBorder b) => + b.Style == HBorderStyle.None ? "none" : $"{b.Style}:{b.WidthMm.ToString("0.###", CultureInfo.InvariantCulture)}:{b.Color}"; + + private static string Gradient(HGradient? g) => g is null ? "-" : $"{string.Join(",", g.Colors)}@{g.Angle}"; + + private static string Join(IEnumerable? values) => values is null ? "-" : string.Join(",", values); + + private static string Hash(string path) => + File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))[..12] : "missing"; + + // ───────────────────────── Tolerant comparison ───────────────────────── + + /// + /// Compares a source model with the model read back from the HWPX written from it. Format members the source + /// leaves open (null = "the 한글 style decides") are not compared; values the writer derives (layout caches such as + /// row heights, relative column widths, snapped line widths, the empty paragraph that carries a section's setup + /// before a table) are compared the way the writer derives them. + /// + public static void Similar(HDocument expected, HDocument actual) + { + var differences = new List(); + new Comparer(differences).Document(expected, actual); + Assert.True(differences.Count == 0, "Documents differ:\n" + string.Join("\n", differences.Take(40))); + } + + private sealed class Comparer(List differences) + { + private readonly Stack _path = new(); + + private void Differ(string message) => differences.Add($"{string.Join("/", _path.Reverse())}: {message}"); + + private void Equal(T expected, T actual, string what) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + Differ($"{what}: expected {expected}, got {actual}"); + } + + /// Compared only when the source sets it. + private void Set(T? expected, T? actual, string what) where T : struct + { + if (expected is { } value && !Nullable.Equals(value, actual)) + Differ($"{what}: expected {value}, got {actual}"); + } + + private void Set(string? expected, string? actual, string what) + { + if (expected is not null && expected != actual) + Differ($"{what}: expected {expected}, got {actual}"); + } + + private void In(string name, Action action) + { + _path.Push(name); + try + { + action(); + } + finally + { + _path.Pop(); + } + } + + public void Document(HDocument expected, HDocument actual) + { + Set(expected.Title, actual.Title, "title"); + var sections = expected.Sections.Count == 0 ? [new HSection()] : expected.Sections; + Equal(sections.Count, actual.Sections.Count, "sections"); + for (var i = 0; i < Math.Min(sections.Count, actual.Sections.Count); i++) + In($"section{i}", () => Section(sections[i], actual.Sections[i])); + } + + private void Section(HSection expected, HSection actual) + { + if (expected.Page is { } page) + Equal(page, actual.Page, "page"); + Equal(expected.Columns, actual.Columns, "columns"); + Equal(expected.HideFirstHeader, actual.HideFirstHeader, "hideFirstHeader"); + Equal(expected.HideFirstFooter, actual.HideFirstFooter, "hideFirstFooter"); + Set(expected.StartPageNumber, actual.StartPageNumber, "start page"); + HeadersFooters("header", expected.Headers, actual.Headers); + HeadersFooters("footer", expected.Footers, actual.Footers); + + // The writer puts the section setup into an empty paragraph when the section starts with a table. + var blocks = expected.Blocks.Count == 0 || expected.Blocks[0] is not HParagraph ? expected.Blocks.Prepend(new HParagraph()).ToList() : expected.Blocks; + Blocks(blocks, actual.Blocks, firstIsSetup: true); + } + + private void HeadersFooters(string kind, List expected, List actual) + { + Equal(expected.Count, actual.Count, kind + "s"); + for (var i = 0; i < Math.Min(expected.Count, actual.Count); i++) + { + In($"{kind}{i}", () => + { + Equal(expected[i].Pages, actual[i].Pages, "pages"); + Blocks(expected[i].Blocks, actual[i].Blocks); + }); + } + } + + private void Blocks(IReadOnlyList expected, IReadOnlyList actual, bool firstIsSetup = false) + { + // Table captions are written as paragraphs after the table. + var flat = expected.SelectMany(b => b is HTable t ? t.Caption.Prepend(b) : [b]).ToList(); + Equal(flat.Count, actual.Count, "blocks"); + for (var i = 0; i < Math.Min(flat.Count, actual.Count); i++) + { + var (e, a) = (flat[i], actual[i]); + var setup = firstIsSetup && i == 0; + In($"{(e is HTable ? "table" : "p")}{i}", () => + { + switch (e) + { + case HParagraph p when a is HParagraph q: + Paragraph(p, q, setup); + break; + case HTable t when a is HTable u: + Table(t, u); + break; + default: + Differ($"expected {e.GetType().Name}, got {a.GetType().Name}"); + break; + } + }); + } + } + + private void Paragraph(HParagraph expected, HParagraph actual, bool setup) + { + Equal(expected.HeadingLevel, actual.HeadingLevel, "heading"); + Equal(expected.PageBreakBefore, actual.PageBreakBefore, "page break"); + Equal(expected.ColumnBreakBefore, actual.ColumnBreakBefore, "column break"); + Equal(expected.ColumnsChange, actual.ColumnsChange, "columns change"); + List(expected.List, actual.List); + ParaFormat(expected.Format, actual.Format, expected.List is not null); + // The mark only shows in empty paragraphs, and not in the one that carries the section setup. + if (expected.Inlines.Count == 0 && !setup) + CharFormat(expected.MarkFormat, actual.MarkFormat, "mark"); + Inlines(expected.Inlines, actual.Inlines); + } + + private void List(HListRef? expected, HListRef? actual) + { + // Continuation paragraphs of a list item are written without numbering. + if (expected is null || !expected.Numbered) + { + if (actual is not null) + Differ("unexpected list"); + return; + } + if (actual is null) + { + Differ("list missing"); + return; + } + Equal(expected.Level, actual.Level, "list level"); + Equal(expected.Numbered, actual.Numbered, "numbered"); + var level = expected.Numbering.Levels[expected.Level]; + if (actual.Level < actual.Numbering.Levels.Count) + Equal(level, actual.Numbering.Levels[actual.Level], "list level definition"); + } + + private void ParaFormat(HParaFormat e, HParaFormat a, bool inList) + { + Set(e.Align, a.Align, "align"); + if (!inList || e.Left is not null) + { + Set(e.Left, a.Left, "left"); + Set(e.FirstLine, a.FirstLine, "first line"); + } + Set(e.Right, a.Right, "right"); + Set(e.Before, a.Before, "before"); + Set(e.After, a.After, "after"); + Set(e.LineSpacing, a.LineSpacing, "line spacing"); + Set(e.KeepWithNext, a.KeepWithNext, "keep with next"); + Set(e.KeepLines, a.KeepLines, "keep lines"); + Set(e.WidowOrphan, a.WidowOrphan, "widow/orphan"); + if (e.Tabs is { Count: > 0 } tabs) + Equal(string.Join(";", tabs), string.Join(";", a.Tabs ?? []), "tabs"); + } + + private void CharFormat(HCharFormat e, HCharFormat a, string what) + { + Set(e.Bold, a.Bold, what + " bold"); + Set(e.Italic, a.Italic, what + " italic"); + Set(e.Underline, a.Underline, what + " underline"); + Set(e.Strike, a.Strike, what + " strike"); + Set(e.Superscript, a.Superscript, what + " superscript"); + Set(e.Subscript, a.Subscript, what + " subscript"); + Set(e.Size, a.Size, what + " size"); + Set(e.Font ?? e.EastAsianFont, a.Font, what + " font"); + Set(e.EastAsianFont ?? e.Font, a.EastAsianFont, what + " East Asian font"); + Set(e.Color, a.Color, what + " color"); + Set(e.Shade, a.Shade, what + " shade"); + Set(e.Spacing, a.Spacing, what + " spacing"); + } + + private void Inlines(List expected, List actual) + { + var e = Normalize(expected); + var a = Normalize(actual); + Equal(string.Join(" ", e.Select(Kind)), string.Join(" ", a.Select(Kind)), "inlines"); + if (e.Count != a.Count) + return; + for (var i = 0; i < e.Count; i++) + { + var (x, y) = (e[i], a[i]); + In(Kind(x), () => Inline(x, y)); + } + } + + private void Inline(HInline expected, HInline actual) + { + switch ((expected, actual)) + { + case (HText x, HText y): + Equal(x.Text, y.Text, "text"); + CharFormat(x.Format, y.Format, "text"); + break; + case (HTab x, HTab y): + CharFormat(x.Format, y.Format, "tab"); + break; + case (HLink x, HLink y): + Equal(x.Target, y.Target, "target"); + Inlines(x.Content, y.Content); + break; + case (HBookmark x, HBookmark y): + Equal(x.Name, y.Name, "bookmark"); + break; + case (HNote x, HNote y): + Equal(x.Endnote, y.Endnote, "endnote"); + Blocks(x.Blocks, y.Blocks); + break; + case (HField x, HField y): + Equal(x.Kind, y.Kind, "field"); + break; + case (HImage x, HImage y): + Equal(Hash(x.Path), Hash(y.Path), "picture content"); + Set(x.Width, y.Width, "width"); + Set(x.Height, y.Height, "height"); + Anchor(x.Anchor, y.Anchor); + break; + case (HTextBox x, HTextBox y): + Equal((x.Width, x.Height), (y.Width, y.Height), "size"); + Anchor(x.Anchor, y.Anchor); + Equal(x.Fill, y.Fill, "fill"); + Equal(Gradient(x.Gradient), Gradient(y.Gradient), "gradient"); + Line(x.Line, y.Line); + Equal(x.Shape, y.Shape, "shape"); + Equal(x.CornerRatio, y.CornerRatio, "corner"); + Equal(x.Padding, y.Padding, "padding"); + Equal(x.VerticalAlign, y.VerticalAlign, "vertical align"); + Blocks(x.Blocks, y.Blocks); + break; + case (HShape x, HShape y): + Equal(x.Kind, y.Kind, "kind"); + // The writer gives lines at least one unit of height or width; the reader takes it back. + Equal((Math.Max(1, x.Width), Math.Max(1, x.Height)), (Math.Max(1, y.Width), Math.Max(1, y.Height)), "size"); + Anchor(x.Anchor, y.Anchor); + Equal(x.Fill, y.Fill, "fill"); + Equal(Gradient(x.Gradient), Gradient(y.Gradient), "gradient"); + Line(x.Line, y.Line); + Equal(x.CornerRatio, y.CornerRatio, "corner"); + Equal((x.FlipHorizontal, x.FlipVertical), (y.FlipHorizontal, y.FlipVertical), "flip"); + break; + } + } + + private void Anchor(HAnchor? expected, HAnchor? actual) + { + if (expected is null || actual is null) + { + Equal(expected is null, actual is null, "floating"); + return; + } + // Offsets into the margin are re-anchored by the writer (see HwpxWriter.NonNegative). + if (expected.HorizontalOffset >= 0 && expected.VerticalOffset >= 0) + Equal(expected, actual, "anchor"); + else + Equal(expected.Wrap, actual.Wrap, "wrap"); + } + + private void Line(HBorder expected, HBorder actual) + { + Equal(expected.Style, actual.Style, "line style"); + if (expected.Style == HBorderStyle.None) + return; + Equal(expected.Color, actual.Color, "line color"); + if (Math.Abs(expected.WidthMm - actual.WidthMm) > 0.01) + Differ($"line width: expected {expected.WidthMm}, got {actual.WidthMm}"); + } + + private void Table(HTable expected, HTable actual) + { + Equal(expected.Rows.Count, actual.Rows.Count, "rows"); + Equal(expected.Align, actual.Align, "align"); + if (expected.CellMargin is { } margin) + Equal(margin, actual.CellMargin, "cell margin"); + // Tables wider than their place are scaled down by the writer, keeping the proportions. + if (expected.ColumnWidths is { } widths && expected.ColumnCount == widths.Length && + (actual.ColumnWidths is not { } got || got.Length != widths.Length || got.Sum() > widths.Sum() || + widths.Zip(got).Any(w => Math.Abs(w.First * (double)got.Sum() / widths.Sum() - w.Second) > 2))) + Differ($"column widths: expected {string.Join(",", widths)}, got {string.Join(",", actual.ColumnWidths ?? [])}"); + for (var r = 0; r < Math.Min(expected.Rows.Count, actual.Rows.Count); r++) + { + var (er, ar) = (expected.Rows[r], actual.Rows[r]); + In($"row{r}", () => + { + Equal(er.Header, ar.Header, "header"); + // Row heights are layout caches the writer estimates; they only grow. + if (er.Height is { } height && !(ar.Height >= height)) + Differ($"row height: expected at least {height}, got {ar.Height}"); + Equal(er.Cells.Count, ar.Cells.Count, "cells"); + for (var c = 0; c < Math.Min(er.Cells.Count, ar.Cells.Count); c++) + { + var (ec, ac) = (er.Cells[c], ar.Cells[c]); + In($"cell{c}", () => + { + if (ec.Column >= 0) + Equal(ec.Column, ac.Column, "column"); + Equal((ec.RowSpan, ec.ColSpan), (ac.RowSpan, ac.ColSpan), "span"); + Equal(ec.VerticalAlign, ac.VerticalAlign, "vertical align"); + Equal(ec.Fill, ac.Fill, "fill"); + Equal(SnappedBorders(ec.Borders ?? expected.Borders ?? HBorders.Grid), SnappedBorders(ac.Borders), "borders"); + Blocks(ec.Blocks.Count == 0 ? [new HParagraph()] : ec.Blocks, ac.Blocks); + }); + } + }); + } + } + + private static string SnappedBorders(HBorders? borders) => borders is { } b + ? string.Join(" ", new[] { b.Left, b.Right, b.Top, b.Bottom }.Select(x => x.Style == HBorderStyle.None ? "none" : $"{x.Style}:{HwpxWriter.LineWidth(x.WidthMm)}:{x.Color}")) + : "-"; + + private static string Kind(HInline inline) => inline switch + { + HText => "T", + HTab => "TAB", + HLineBreak => "BR", + HLink => "LINK", + HBookmark => "BM", + HNote => "NOTE", + HField f => f.Kind.ToString(), + HImage => "IMG", + HTextBox => "BOX", + HShape s => s.Kind.ToString(), + _ => inline.GetType().Name, + }; + + /// Adjacent texts with the same format are one run in 한글; empty texts disappear. + private static List Normalize(List inlines) + { + var result = new List(); + foreach (var inline in inlines) + { + if (inline is HText { Text.Length: 0 }) + continue; + if (inline is HText text && result.Count > 0 && result[^1] is HText last && last.Format == text.Format) + result[^1] = new HText(last.Text + text.Text, text.Format); + else + result.Add(inline); + } + return result; + } + } +} diff --git a/tests/Filee.Engines.Tests/HwpxReaderTests.cs b/tests/Filee.Engines.Tests/HwpxReaderTests.cs new file mode 100644 index 0000000..9a041cc --- /dev/null +++ b/tests/Filee.Engines.Tests/HwpxReaderTests.cs @@ -0,0 +1,805 @@ +// HWPX → document model (HwpxReader), the inverse of HwpxWriter: documents from the other readers (DOCX, Markdown, +// XLSX, PPTX) are written as HWPX and read back, then written and read again, which must give the same model. +// Files converted by rhwp, the template saved by 한글 and a package in the style 한글 writes (other prefixes, +// hp:switch branches, bullets, automatic page numbers, groups, tracked changes ...) are read too. + +using System.Diagnostics; +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Hwp.Hwpx; +using Filee.Engines.Hwp.Hwpx.Docx; +using Filee.Engines.Hwp.Hwpx.Pptx; +using Filee.Engines.Office.Sheets; +using ImageMagick; +using static Filee.Engines.Tests.DocxBuilder; + +namespace Filee.Engines.Tests; + +public class HwpxReaderTests(EngineFixture fx) : IClassFixture +{ + /// + /// Writes , reads it back and compares; writes the result again and checks that reading + /// it gives the same model and that the second file matches the first. Table row heights are layout caches the + /// writer estimates from the model: where the source leaves spacing to the style, the model read back states it, + /// so the second estimate may only be larger. + /// + private (HDocument Read, string Hwpx) RoundTrip(HDocument source, string name) + { + var dir = fx.NewFolder(); + var first = Path.Combine(dir, name + ".hwpx"); + HwpxWriter.Write(source, first); + var skipped = new Dictionary(); + var read = HwpxReader.Read(first, Path.Combine(dir, "media-1"), skipped); + Assert.Empty(skipped); // everything Filee writes has a place in the model + HDocumentAssert.Similar(source, read); + + var second = Path.Combine(dir, name + "-again.hwpx"); + HwpxWriter.Write(read, second); + var again = HwpxReader.Read(second, Path.Combine(dir, "media-2")); + Assert.Equal(HDocumentAssert.Dump(read, layout: false), HDocumentAssert.Dump(again, layout: false)); + var heights = (HDocument d) => d.Sections.SelectMany(s => s.Blocks).OfType().SelectMany(t => t.Rows).Select(r => r.Height ?? 0).ToList(); + Assert.All(heights(read).Zip(heights(again)), h => Assert.True(h.Second >= h.First)); + Assert.Equal(Outline(first), Outline(second)); + return (read, first); + } + + /// + /// The section parts as a sequence of elements and text, without run boundaries and the ids that point into + /// header.xml (formats are compared on the model), and without table heights (layout caches). + /// + private static string Outline(string hwpx) + { + using var zip = ZipFile.OpenRead(hwpx); + var outline = new StringBuilder(); + foreach (var entry in zip.Entries.Where(e => e.FullName.StartsWith("Contents/section", StringComparison.Ordinal)).OrderBy(e => e.FullName)) + { + var root = XDocument.Load(entry.Open(), LoadOptions.PreserveWhitespace).Root!; + foreach (var node in root.DescendantNodesAndSelf()) + { + if (node is XText text) + { + outline.Append(text.Value); + continue; + } + if (node is not XElement element || element.Name.LocalName is "run" or "t") + continue; + var tableHeight = element.Name.LocalName == "cellSz" || (element.Name.LocalName == "sz" && element.Parent?.Name.LocalName == "tbl"); + var attributes = element.Attributes().Where(a => !a.IsNamespaceDeclaration && !a.Name.LocalName.EndsWith("IDRef", StringComparison.Ordinal) && + !(tableHeight && a.Name.LocalName == "height")); + outline.Append('<').Append(element.Name.LocalName).Append(string.Concat(attributes.Select(a => $" {a.Name.LocalName}={a.Value}"))).Append('>'); + } + } + return outline.ToString(); + } + + private static byte[] Png(MagickColor color, uint width = 120, uint height = 60) + { + using var image = new MagickImage(color, width, height); + return image.ToByteArray(MagickFormat.Png); + } + + private static IEnumerable AllInlines(IEnumerable blocks) => blocks.SelectMany(b => b switch + { + HParagraph p => p.Inlines.SelectMany(i => i switch + { + HLink l => l.Content.Prepend(l), + HNote n => AllInlines(n.Blocks).Prepend(n), + HTextBox t => AllInlines(t.Blocks).Prepend(t), + _ => [i], + }), + HTable t => AllInlines(t.Rows.SelectMany(r => r.Cells).SelectMany(c => c.Blocks).Concat(t.Caption)), + _ => [], + }); + + private static string Text(IEnumerable blocks) => string.Concat(AllInlines(blocks).OfType().Select(t => t.Text)); + + // ───────────────────────── Round trips ───────────────────────── + + /// + /// Two sections (portrait with odd/even headers and a hidden first header, then landscape with two columns), + /// formatting, lists, tab stops, line spacing kinds, a table with spans, borders, shading and a nested table, + /// pictures inline and floating, a text box, shapes, footnote, endnote, links, bookmark and breaks. + /// + private string RichDocx(string folder) + { + var docx = new DocxBuilder + { + Numbering = """ + + + + + + + + """, + Settings = "", + }; + var picture = docx.Image(Png(MagickColors.MediumPurple)); + var header = docx.Header(P(R("홀수 쪽 머리말"), "")); + var evenHeader = docx.Header(P(R("짝수 쪽 머리말"))); + var footer = docx.Footer(P(R("- ") + Field("PAGE", "1") + R(" / ") + Field("NUMPAGES", "3") + R(" -"), "")); + docx.Footnotes("" + R(" 각주 설명") + ""); + docx.Endnotes("" + R(" 미주 설명") + ""); + var url = docx.Hyperlink("https://example.com/a?b=1"); + var cell = (string content, string tcPr) => $"{tcPr}{content}"; + const string Column = "0"; + const string Paragraph = "0"; + string Shape(string geometry, string fill, string line, long cx, long cy, string text = "") => + $"{fill}{line}" + + (text.Length > 0 ? $"{P(R(text))}" : "") + ""; + var nested = "" + + "" + cell(P(R("안1")), "") + cell(P(R("안2")), "") + "" + P(""); + + docx.Paragraph(P("" + R("가운데 제목") + "", "")) + .Paragraph(P(R("굵은 빨강", "") + + R(" 형광펜", "") + R(" 기울임", "") + + R("위첨자", "") + R("아래첨자", "") + + R("자간", "") + "" + R("탭 뒤"), + "")) + .Paragraph(P(R("고정 줄 간격"), "")) + .Paragraph(P(R("최소 줄 간격"), "")) + .Paragraph(P(R("첫 항목"), "")) + .Paragraph(P(R("하위 항목"), "")) + .Paragraph(P(R("글머리 항목"), "")) + .Paragraph(P(R("본문") + "" + R("미주") + "" + + $"" + R("웹 링크", "") + "" + + "" + R("소개로 이동") + "" + R("줄") + "" + R("바꿈"))) + .Paragraph("" + + "" + + "" + cell(P(R("가로 병합")), "") + + cell(P(R("세로 병합")), "") + "" + + "" + cell(nested, "") + cell(P(R("B")), "") + cell(P(""), "") + "" + + "" + cell(P(R("C")), "") + + cell(P(R("D")), "") + cell(P(R("E")), "") + "" + + "") + .Paragraph(P(Anchor(PictureUri, Picture(picture, 914400, 457200), 914400, 457200, + "914400", Paragraph) + R("그림 옆에 흐르는 글"))) + .Paragraph(P(InlinePicture(picture, 457200, 457200))) + .Paragraph(P(Anchor(ShapeUri, + Shape("roundRect", "", "", 1828800, 914400, "글상자 안의 글"), + 1828800, 914400, "center", + "3600000", ""))) + .Paragraph(P(Anchor(ShapeUri, Shape("line", "", "", 3000000, 0), + 3000000, 0, Column, Paragraph, ""))) + .Paragraph(P(Anchor(ShapeUri, + Shape("ellipse", "", "", 1000000, 500000), + 1000000, 500000, Column, Paragraph, ""))) + .Paragraph(P(R("앞 쪽") + "" + R("다음 쪽"))) + .Paragraph(P(R("첫 구역 끝"), + $"" + + $"{DefaultPage}")) + .Paragraph(P(R("가로 두 단"))) + .Paragraph(P(R("다음 단")) + P("" + R("둘째 단"))); + docx.SectionProperties = + "" + + ""; + return docx.Save(Path.Combine(folder, "rich.docx")); + } + + [Fact] + public void Docx_documents_survive_the_way_through_hwpx() + { + var dir = fx.NewFolder(); + var source = DocxReader.Read(RichDocx(dir), Path.Combine(dir, "docx-media")); + + var (read, _) = RoundTrip(source, "docx"); + + // Spot checks on top of the structural comparison. + Assert.Equal(2, read.Sections.Count); + var first = read.Sections[0]; + Assert.Equal([HPageType.Odd, HPageType.Even], first.Headers.Select(h => h.Pages)); + Assert.Equal("홀수 쪽 머리말", Text(first.Headers[0].Blocks)); + Assert.True(first.HideFirstHeader); + Assert.Equal(3, first.StartPageNumber); + Assert.Equal([HFieldKind.PageNumber, HFieldKind.TotalPages], AllInlines(first.Footers.Single().Blocks).OfType().Select(f => f.Kind)); + var landscape = read.Sections[1]; + Assert.True(landscape.Page!.Landscape); + Assert.Equal((16838 * 5, 11906 * 5), (landscape.Page.Width, landscape.Page.Height)); + Assert.Equal(new HColumns(2, 3600, true), landscape.Columns); + + // Hanging indent: the model measures "left" to the second line like Word. + var indented = first.Blocks.OfType().Single(p => Text([p]).StartsWith("굵은 빨강", StringComparison.Ordinal)); + Assert.Equal((3600, -1800), (indented.Format.Left, indented.Format.FirstLine)); + var red = AllInlines([indented]).OfType().First(); + Assert.Equal((true, 1400, "Arial", "굴림", "#FF0000"), (red.Format.Bold, red.Format.Size, red.Format.Font, red.Format.EastAsianFont, red.Format.Color)); + + var notes = AllInlines(first.Blocks).OfType().ToList(); + Assert.Equal([("각주 설명", false), ("미주 설명", true)], notes.Select(n => (Text(n.Blocks), n.Endnote))); + var links = AllInlines(first.Blocks).OfType().ToList(); + Assert.Equal(["https://example.com/a?b=1", "#intro"], links.Select(l => l.Target)); + + var table = first.Blocks.OfType().Single(); + Assert.True(table.Rows[0].Header); + Assert.Equal((1, 2), (table.Rows[0].Cells[0].RowSpan, table.Rows[0].Cells[0].ColSpan)); + Assert.Equal((2, 1), (table.Rows[0].Cells[1].RowSpan, table.Rows[0].Cells[1].ColSpan)); + Assert.Equal("안1안2", Text([Assert.IsType(table.Rows[1].Cells[0].Blocks[0])])); + + Assert.Equal(2, AllInlines(first.Blocks).OfType().Count()); + Assert.Contains(AllInlines(first.Blocks), i => i is HTextBox { Fill: "#FFF2CC" } box && Text(box.Blocks) == "글상자 안의 글"); + Assert.Contains(AllInlines(first.Blocks), i => i is HShape { Kind: HShapeKind.Ellipse, Gradient.Colors.Count: 2 }); + Assert.Contains(first.Blocks, b => b is HParagraph { PageBreakBefore: true }); + Assert.Contains(landscape.Blocks, b => b is HParagraph { ColumnBreakBefore: true }); + } + + [Fact] + public void Markdown_documents_survive_the_way_through_hwpx() + { + var dir = fx.NewFolder(); + using (var logo = new MagickImage(MagickColors.MediumPurple, 240, 120)) + logo.Write(Path.Combine(dir, "logo.png")); + const string Markdown = """ + --- + title: 마크다운 제목 + --- + + # 첫 번째 제목 + + 일반 문단에 **굵게**, *기울임*, ~~취소선~~, H~2~O, x^2^, `코드` 그리고 [링크](https://example.com) 가 있습니다.[^1] + + > 인용문 + + 3. 셋째부터 시작 + 4. 다음 항목 + - 중첩 글머리 + + | 이름 | 값 | + |------|---:| + | 표 셀 | 42 | + + ![로고](logo.png) + + ``` + 코드 블록 + ``` + + [^1]: 각주 내용입니다. + """; + var source = MarkdownReader.Read(Markdown, dir); + + var (read, _) = RoundTrip(source, "markdown"); + + Assert.Equal("마크다운 제목", read.Title); + var blocks = read.Sections.Single().Blocks; + Assert.Equal(1, blocks.OfType().First(p => p.Inlines.Count > 0).HeadingLevel); + Assert.Contains("각주 내용입니다", Text(AllInlines(blocks).OfType().Single().Blocks)); + Assert.Equal(3, blocks.OfType().Single(p => Text([p]) == "셋째부터 시작").List!.Numbering.Levels[0].Start); + Assert.Single(AllInlines(blocks).OfType()); + } + + [Fact] + public void Spreadsheets_survive_the_way_through_hwpx() + { + var xlsx = new XlsxBuilder(); + xlsx.Sheet("매출", + XlsxBuilder.Row(1, xlsx.Text("A1", "지점별 매출", XlsxBuilder.Header)) + + XlsxBuilder.Row(2, xlsx.Text("A2", "지점", XlsxBuilder.Header), xlsx.Text("B2", "매출", XlsxBuilder.Header)) + + XlsxBuilder.Row(3, xlsx.Text("A3", "서울"), XlsxBuilder.Number("B3", 1350000, XlsxBuilder.Won)) + + XlsxBuilder.Row(4, xlsx.Text("A4", "합계"), $"1350000"), + before: "", + after: ""); + xlsx.Sheet("넓은 표", XlsxBuilder.Row(1, [.. Enumerable.Range(0, 12).Select(c => xlsx.Text($"{(char)('A' + c)}1", $"항목 {c + 1}"))]), + before: ""); + var source = SheetDocument.Build(XlsxReader.Read(xlsx.Save(Path.Combine(fx.NewFolder(), "매출.xlsx")))); + + var (read, _) = RoundTrip(source, "xlsx"); + + var table = read.Sections[0].Blocks.OfType().Single(); + Assert.Equal((1, 2), (table.Rows[0].Cells[0].RowSpan, table.Rows[0].Cells[0].ColSpan)); + Assert.Equal("#1F4E79", table.Rows[0].Cells[0].Fill); + Assert.Equal([true, true, false, false], table.Rows.Select(r => r.Header)); + Assert.True(read.Sections[1].Page!.Landscape); + } + + [Fact] + public void Presentations_survive_the_way_through_hwpx() + { + var pptx = new PptxBuilder(); + var chart = pptx.Image(Png(MagickColors.Teal, 160, 90)); + pptx.Slide(pptx.Title("2026년 사업 보고")); + pptx.Slide( + pptx.Title("다음 분기 계획") + + pptx.Body(("매출 증가", 0), ("지점 확대", 1)) + + pptx.Shape("roundRect", 838200, 4400000, 3000000, 1500000, "#7F77DD", "강조") + + pptx.Picture(chart, 8000000, 1900000, 3000000, 1687500) + + pptx.Table(4000000, 4400000, 1200000, 370840, ["지점", "매출"], ["강남", "16.2억"]) + + pptx.Group(6000000, 4000000, 2000000, 1000000, 0.5, pptx.Shape("ellipse", 0, 0, 4000000, 2000000, "#1D9E75", "단계 1")), + background: "#1F1E2D"); + var dir = fx.NewFolder(); + var source = PptxReader.Read(pptx.Save(Path.Combine(dir, "계획.pptx")), Path.Combine(dir, "pptx-media")); + + var (read, _) = RoundTrip(source, "pptx"); + + var inlines = AllInlines(read.Sections.SelectMany(s => s.Blocks)).ToList(); + Assert.Contains(inlines, i => i is HTextBox { Shape: HShapeKind.Ellipse } box && Text(box.Blocks) == "단계 1"); + Assert.Single(inlines.OfType(), i => i.Anchor is { HorizontalRelativeTo: "PAPER" }); + Assert.All(inlines.OfType(), b => Assert.NotNull(b.Anchor)); + } + + // ───────────────────────── Other producers ───────────────────────── + + [Fact] + public async Task Files_converted_by_rhwp_are_read_like_the_original() + { + var rhwp = fx.Converters.Single(c => c.Id == "rhwp"); + Assert.SkipUnless(fx.Catalog.StatusOf(rhwp).IsAvailable, "rhwp not available"); + var dir = fx.NewFolder(); + var hwpx = Path.Combine(dir, "original.hwpx"); + HwpxWriter.Write(DocxReader.Read(RichDocx(dir), Path.Combine(dir, "docx-media")), hwpx); + + // HWPX → HWP → HWPX: the HWPX rhwp writes for an HWP file, the way HWP documents reach the reader. + async Task Convert(string input, string from, string to) + { + var output = Path.Combine(dir, $"{Path.GetFileNameWithoutExtension(input)}-rhwp.{to}"); + var written = await rhwp.ConvertAsync(new ConversionStep(input, from, to, new Preset(), new FixedPath(output), dir), null, TestContext.Current.CancellationToken); + return written.Single(); + } + var fromHwp = await Convert(await Convert(hwpx, "hwpx", "hwp"), "hwp", "hwpx"); + + var skipped = new Dictionary(); + var original = HwpxReader.Read(hwpx, Path.Combine(dir, "media-original")); + var converted = HwpxReader.Read(fromHwp, Path.Combine(dir, "media-rhwp"), skipped); + + Assert.Empty(skipped); + HDocumentAssert.Similar(original, converted); + } + + [Fact] + public void The_template_saved_by_hangul_is_read() + { + // blank.hwpx (from pypandoc-hwpx, MIT) was saved by Hancom Office Hangul 12.30. + var dir = fx.NewFolder(); + var path = Path.Combine(dir, "blank.hwpx"); + using (var template = HwpxWriter.OpenBuiltInTemplate()) + using (var file = File.Create(path)) + template.CopyTo(file); + + var document = HwpxReader.Read(path, Path.Combine(dir, "media")); + + var section = Assert.Single(document.Sections); + Assert.Equal(new HPage(59530, 84190, 7200, 7200, 4255, 4960, 4250, 2240, 0), section.Page); + Assert.Equal(HColumns.Single, section.Columns); + var paragraph = Assert.IsType(Assert.Single(section.Blocks)); + Assert.Empty(paragraph.Inlines); + // Normal paragraph shape: 180% line spacing, 10 pt after (hp:case), not the doubled hp:default values. + Assert.Equal(new HLineSpacing(HLineSpacingKind.Percent, 180), paragraph.Format.LineSpacing); + Assert.Equal(1000, paragraph.Format.After); + Assert.Equal(1000, paragraph.MarkFormat.Size); + } + + [Fact] + public void Packages_in_the_style_of_hangul_are_read() + { + var path = HangulStylePackage(fx.NewFolder()); + var skipped = new Dictionary(); + + var document = HwpxReader.Read(path, Path.Combine(Path.GetDirectoryName(path)!, "media"), skipped); + + Assert.Equal("한글 문서 제목", document.Title); + Assert.Equal(2, document.Sections.Count); + var section = document.Sections[0]; + Assert.Equal(new HPage(84188, 59528, 8504, 8504, 5668, 4252, 4252, 4252, 0), section.Page); + Assert.Equal(new HColumns(2, 850, true), section.Columns); + Assert.True(section.HideFirstHeader); + Assert.Equal(5, section.StartPageNumber); + Assert.Equal("홀수 쪽 머리말", Text(Assert.Single(section.Headers, h => h.Pages == HPageType.Odd).Blocks)); + // Automatic page number (hp:pageNum) at the bottom centre with dashes. + var pageNumber = Assert.IsType(Assert.Single(Assert.Single(section.Footers).Blocks)); + Assert.Equal(HAlign.Center, pageNumber.Format.Align); + Assert.Equal(["T:- ", "PageNumber", "T: -"], pageNumber.Inlines.Select(i => i is HText t ? "T:" + t.Text : ((HField)i).Kind.ToString())); + + var blocks = section.Blocks; + var heading = Assert.IsType(blocks[0]); + Assert.Equal((1, "제1장 개요"), (heading.HeadingLevel, Text([heading]))); + Assert.Equal(("^1.", 0), (heading.List!.Numbering.Levels[0].Text, heading.List.Level)); // outline numbering + Assert.Equal(1000, heading.Format.Left); // margin without a switch: the doubled scale, halved + Assert.Equal(new HLineSpacing(HLineSpacingKind.Percent, 150), heading.Format.LineSpacing); + + var formatted = Assert.IsType(blocks[1]); + Assert.Equal(new HParaFormat(HAlign.Left, 3000, 500, -2000, 400, 600, new HLineSpacing(HLineSpacingKind.Fixed, 1800), true, true, true, + [new HTabStop(20000, HTabKind.Center, "DASH")]), formatted.Format); + var texts = formatted.Inlines.Select(i => i switch + { + HText t => t.Text, + HTab => "", + HLineBreak => "
", + _ => "?", + }); + Assert.Equal(["굵게", "보통", "형광", " 묶음 빈칸 고정", "", "탭", "
", "둘째 줄넣은 글하이­픈"], texts); + var bold = ((HText)formatted.Inlines[0]).Format; + Assert.Equal(new HCharFormat(true, true, true, true, true, false, 1400, "Times New Roman", "맑은 고딕", "#0000FF", "#FFFF00", -5), bold); + Assert.Equal("#00FF00", ((HText)formatted.Inlines[2]).Format.Shade); // highlighter (markpen) + Assert.Equal(new HCharFormat(false, false, false, false, false, false, 1000, "함초롬바탕", "함초롬바탕", "#000000", null, 0), ((HText)formatted.Inlines[1]).Format); + + var links = Assert.IsType(blocks[2]).Inlines; + Assert.Equal("처음", Assert.IsType(links[0]).Name); + var web = Assert.IsType(links[1]); + Assert.Equal(("http://www.hancom.com?a=1", "한컴링크"), (web.Target, Text([ParagraphOf(web.Content)]))); + Assert.Equal(" 뒤", Assert.IsType(links[2]).Text); + Assert.Equal("#처음", Assert.IsType(links[3]).Target); + Assert.Equal("누름틀", Assert.IsType(links[4]).Text); // other fields keep their text + + var bullet = Assert.IsType(blocks[3]).List!; + Assert.Equal(("●", true), (bullet.Numbering.Levels[0].Text, bullet.Numbering.Levels[0].Bullet)); // private-use bullet + var numbered = Assert.IsType(blocks[4]); + Assert.Equal((1, "(^2)", "ROMAN_SMALL", 3), (numbered.List!.Level, numbered.List.Numbering.Levels[1].Text, numbered.List.Numbering.Levels[1].Format, numbered.List.Numbering.Levels[1].Start)); + Assert.True(numbered.PageBreakBefore); + Assert.Equal(new HLineSpacing(HLineSpacingKind.AtLeast, 1500), Assert.IsType(blocks[5]).Format.LineSpacing); + + // A table inside a paragraph splits it: text before, the table, text after. + Assert.Equal("표 앞", Text([blocks[6]])); + var table = Assert.IsType(blocks[7]); + Assert.Equal("표 뒤", Text([blocks[8]])); + Assert.Equal((HAlign.Right, new HInsets(141, 141, 141, 141)), (table.Align, table.CellMargin)); + Assert.Equal([8000, 12000, 10000], table.ColumnWidths!); + Assert.Equal("표 1 캡션", Text(table.Caption)); + var merged = table.Rows[0].Cells[0]; + Assert.Equal((0, 1, 2, "#FFFF00", HVerticalAlign.Center), (merged.Column, merged.RowSpan, merged.ColSpan, merged.Fill, merged.VerticalAlign)); + Assert.Equal(new HBorders(new HBorder(HBorderStyle.Solid, 0.12, "#000000"), new HBorder(HBorderStyle.Double, 0.5, "#FF0000"), + new HBorder(HBorderStyle.Dash, 0.2, "#0000FF"), new HBorder(HBorderStyle.Dot, 0.12, "#000000")), merged.Borders); + Assert.Equal((2, 2, 1, HVerticalAlign.Bottom), (table.Rows[0].Cells[1].Column, table.Rows[0].Cells[1].RowSpan, table.Rows[0].Cells[1].ColSpan, table.Rows[0].Cells[1].VerticalAlign)); + Assert.Equal([true, false], table.Rows.Select(r => r.Header)); + Assert.Equal([1500, 1500], table.Rows.Select(r => r.Height)); + Assert.Equal("안쪽", Text([Assert.IsType(Assert.Single(table.Rows[1].Cells[0].Blocks))])); + + var pictures = Assert.IsType(blocks[9]).Inlines.OfType().ToList(); + Assert.Equal(2, pictures.Count); // the third refers to a missing image + Assert.Equal((5000, 2500), (pictures[0].Width, pictures[0].Height)); + Assert.Equal(new HAnchor("COLUMN", "LEFT", 1000, "PARA", "TOP", -100, "SQUARE", false), pictures[0].Anchor); + Assert.Null(pictures[1].Anchor); + Assert.All(pictures, p => Assert.True(new MagickImageInfo(p.Path).Width > 0)); + + // Group members float at their place in the group. + var group = Assert.IsType(blocks[10]).Inlines; + var box = Assert.IsType(group[0]); + Assert.Equal((12000, 2000, 2000, 2500), (box.Width, box.Height, box.Anchor!.HorizontalOffset, box.Anchor.VerticalOffset)); + Assert.Equal(("#FF0000,#0000FF", 90, HBorderStyle.Dash, 20), (string.Join(",", box.Gradient!.Colors), box.Gradient.Angle, box.Line.Style, box.CornerRatio)); + Assert.Equal((new HInsets(100, 100, 50, 50), HVerticalAlign.Center, "묶음 글상자"), (box.Padding, box.VerticalAlign, Text(box.Blocks))); + var ellipse = Assert.IsType(group[1]); + Assert.Equal((HShapeKind.Ellipse, 6000, 13000, "#00FF00", 0.1), (ellipse.Kind, ellipse.Width, ellipse.Anchor!.HorizontalOffset, ellipse.Fill, ellipse.Line.WidthMm)); + + Assert.Equal("식: x over y 12본말", Text([blocks[11]])); // equation script, overlapping characters, ruby base text + var notes = Assert.IsType(blocks[12]).Inlines.OfType().ToList(); + Assert.Equal([("각주 내용", false), ("미주 내용", true)], notes.Select(n => (Text(n.Blocks), n.Endnote))); + Assert.Equal("차트 뒤", Text([blocks[13]])); + Assert.True(Assert.IsType(blocks[14]).ColumnBreakBefore); + + // Other prefixes (a default namespace) in the second section; hp:newNum restarts page numbers. + var second = document.Sections[1]; + Assert.Equal((59528, 84188, false), (second.Page!.Width, second.Page.Height, second.Page.Landscape)); + Assert.Equal(3, second.StartPageNumber); + Assert.Equal("둘째 구역", Text(second.Blocks)); + Assert.True(((HText)((HParagraph)second.Blocks[0]).Inlines[0]).Format.Bold); + + Assert.Equal(new Dictionary { ["ole"] = 1, ["pic"] = 1 }, skipped); + } + + private static HParagraph ParagraphOf(IEnumerable inlines) + { + var paragraph = new HParagraph(); + paragraph.Inlines.AddRange(inlines); + return paragraph; + } + + // ───────────────────────── Errors and speed ───────────────────────── + + [Fact] + public void Protected_and_foreign_files_give_a_clear_error() + { + var dir = fx.NewFolder(); + var media = Path.Combine(dir, "media"); + + var encrypted = Path.Combine(dir, "encrypted.hwpx"); + using (var zip = ZipFile.Open(encrypted, ZipArchiveMode.Create)) + { + Write(zip, "mimetype", "application/hwp+zip"); + Write(zip, "META-INF/manifest.xml", + "" + + ""); + Write(zip, "Contents/section0.xml", "\u0001\u0002 not xml"); + } + Assert.Contains("password", Assert.Throws(() => HwpxReader.Read(encrypted, media)).Message); + + var drm = Path.Combine(dir, "drm.hwpx"); + File.WriteAllBytes(drm, Encoding.ASCII.GetBytes("This is a protected document")); + Assert.Contains("DRM", Assert.Throws(() => HwpxReader.Read(drm, media)).Message); + + var hwp = Path.Combine(dir, "binary.hwpx"); + File.WriteAllBytes(hwp, [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0, 0, 0]); + Assert.Contains("HWP 5.0", Assert.Throws(() => HwpxReader.Read(hwp, media)).Message); + + var broken = Path.Combine(dir, "broken.hwpx"); + using (var zip = ZipFile.Open(broken, ZipArchiveMode.Create)) + { + Write(zip, "mimetype", "application/hwp+zip"); + Write(zip, "Contents/header.xml", ""); + Write(zip, "Contents/section0.xml", "PK\u0003\u0004 encrypted bytes"); + } + Assert.Contains("encrypted", Assert.Throws(() => HwpxReader.Read(broken, media)).Message); + } + + [Fact] + public void A_hundred_pages_are_read_in_well_under_a_second() + { + // About 100 A4 pages: 1 300 paragraphs of three lines with mixed formatting, and 26 tables of 10 rows. + var section = new HSection(); + var bold = new HCharFormat(Bold: true, Size: 1100); + for (var i = 0; i < 1300; i++) + { + var paragraph = new HParagraph { Format = new HParaFormat(Align: HAlign.Justify, After: 400) }; + paragraph.Inlines.Add(new HText($"{i + 1}. ", bold)); + paragraph.Inlines.Add(new HText(string.Concat(Enumerable.Repeat("한글 문서를 읽는 속도를 확인하는 문장입니다. ", 4)), default)); + paragraph.Inlines.Add(new HText("강조", new HCharFormat(Italic: true, Color: "#C00000"))); + section.Blocks.Add(paragraph); + if (i % 50 == 49) + { + var table = new HTable { ColumnCount = 5 }; + for (var r = 0; r < 10; r++) + { + var row = new HRow(); + for (var c = 0; c < 5; c++) + { + var cell = new HCell(); + var text = new HParagraph(); + text.Inlines.Add(new HText($"셀 {r},{c}", default)); + cell.Blocks.Add(text); + row.Cells.Add(cell); + } + table.Rows.Add(row); + } + section.Blocks.Add(table); + } + } + var document = new HDocument(); + document.Sections.Add(section); + var dir = fx.NewFolder(); + var path = Path.Combine(dir, "long.hwpx"); + HwpxWriter.Write(document, path); + + HwpxReader.Read(path, Path.Combine(dir, "warm-up")); // JIT + var best = TimeSpan.MaxValue; + HDocument? read = null; + for (var run = 0; run < 5; run++) + { + var watch = Stopwatch.StartNew(); + read = HwpxReader.Read(path, Path.Combine(dir, $"media-{run}")); + best = watch.Elapsed < best ? watch.Elapsed : best; + } + + Assert.Equal(1326, read!.Sections.Single().Blocks.Count); + // Measured in a debug build while other tests run in parallel. Most of the time is parsing the 1.5 MB section. + Assert.True(best < TimeSpan.FromSeconds(1), $"Reading took {best.TotalMilliseconds:0} ms"); + } + + // ───────────────────────── A package like 한글 writes it ───────────────────────── + + private static void Write(ZipArchive zip, string name, string text, CompressionLevel level = CompressionLevel.Optimal) + { + using var writer = new StreamWriter(zip.CreateEntry(name, level).Open(), new UTF8Encoding(false)); + writer.Write(text); + } + + /// + /// A two-section package with the features and variants of files saved by 한글: hp:switch branches (exact and + /// doubled values, a 2016 heading), bullets, outline numbering, automatic page numbers, fields, highlighter, + /// tracked changes, special spaces, a caption, nested and merged cells, pictures (one without a manifest entry, + /// one missing), a group, equation, compose, ruby text, notes, a chart with an OLE fallback, and in the second + /// section other namespace prefixes. + /// + private static string HangulStylePackage(string folder) + { + const string Namespaces = + "xmlns:ha=\"http://www.hancom.co.kr/hwpml/2011/app\" xmlns:hp=\"http://www.hancom.co.kr/hwpml/2011/paragraph\" " + + "xmlns:hp10=\"http://www.hancom.co.kr/hwpml/2016/paragraph\" xmlns:hs=\"http://www.hancom.co.kr/hwpml/2011/section\" " + + "xmlns:hc=\"http://www.hancom.co.kr/hwpml/2011/core\" xmlns:hh=\"http://www.hancom.co.kr/hwpml/2011/head\""; + const string Case = ""; + static string Border(string type, string width, string color, string fill = "") => + "" + type + width + color + fill; + static string CharPr(string id, string attributes, string fontRef, string spacing, string flags) => + $"" + + $"" + + "" + + $"" + + "" + + "" + flags + ""; + static string Margins(int intent, int left, int right, int prev, int next) => + $""; + static string ParaPr(string id, string tabPr, string align, string heading, string breaks, string spacing) => + $"" + + $"{heading}" + + $"" + + $"{spacing}"; + static string Switch(string exact, string doubled) => $"{Case}{exact}{doubled}"; + const string NoBreaks = "widowOrphan=\"0\" keepWithNext=\"0\" keepLines=\"0\" pageBreakBefore=\"0\""; + const string Percent160 = ""; + static string Heading(string type, int id, int level) => $""; + static string ParaHead(int level, string format, string text, int start = 1) => + $"{text}"; + + var header = + $"" + + "" + + "" + + "" + + "" + + "" + + Border("", + "", + "") + "" + + "" + + Border("", + "", + "", + "") + "" + + "" + + CharPr("0", "height=\"1000\" textColor=\"#000000\" shadeColor=\"none\"", "0", "0", + "") + + CharPr("1", "height=\"1400\" textColor=\"#0000ff\" shadeColor=\"#FFFF00\"", "1", "-5", + "") + + "" + + Switch("", "") + + "" + + "" + ParaHead(1, "DIGIT", "^1.") + ParaHead(2, "HANGUL_SYLLABLE", "^2.") + "" + + "" + ParaHead(1, "DIGIT", "^1)") + ParaHead(2, "ROMAN_SMALL", "(^2)", start: 3) + "" + + "" + + "" + + "" + + ParaPr("0", "0", "JUSTIFY", Heading("NONE", 0, 0), NoBreaks, Switch(Margins(0, 0, 0, 0, 0) + Percent160, Margins(0, 0, 0, 0, 0) + Percent160)) + + ParaPr("1", "1", "LEFT", Heading("NONE", 0, 0), "widowOrphan=\"1\" keepWithNext=\"1\" keepLines=\"1\" pageBreakBefore=\"0\"", + Switch(Margins(-2000, 1000, 500, 400, 600) + "", + Margins(-4000, 2000, 1000, 800, 1200) + "")) + + // An older paragraph shape without a switch: lengths in the doubled scale. + ParaPr("2", "0", "LEFT", Heading("OUTLINE", 0, 0), NoBreaks, Margins(0, 2000, 0, 0, 0) + "") + + ParaPr("3", "0", "LEFT", Heading("BULLET", 1, 0), NoBreaks, Switch(Margins(0, 0, 0, 0, 0) + Percent160, Margins(0, 0, 0, 0, 0) + Percent160)) + + ParaPr("4", "0", "LEFT", + "" + Heading("NUMBER", 2, 1) + "" + Heading("NONE", 0, 0) + "", + "widowOrphan=\"0\" keepWithNext=\"0\" keepLines=\"0\" pageBreakBefore=\"1\"", Switch(Margins(0, 0, 0, 0, 0) + Percent160, Margins(0, 0, 0, 0, 0) + Percent160)) + + ParaPr("5", "0", "LEFT", Heading("NONE", 0, 0), NoBreaks, + Switch(Margins(0, 0, 0, 0, 0) + "", Margins(0, 0, 0, 0, 0) + "")) + + "" + + "" + + "" + + ""; + + static string P(string paraPr, string runs, bool columnBreak = false) => + $"{runs}" + + ""; + static string R(string content, string charPr = "0") => $"{content}"; + static string T(string text) => $"{text}"; + static string SubList(string paragraphs, string vertAlign = "TOP") => + $"{paragraphs}"; + static string Cell(string content, int column, int row, int colSpan, int rowSpan, int width, int height, string borderFill = "1", bool header = false, string vertAlign = "TOP") => + $"{SubList(content, vertAlign)}" + + $"" + + ""; + static string Pos(bool inline, int x, int y, string horzAlign = "LEFT") => + $""; + static string Picture(string item, int width, int height, string pos) => + $"" + + $"" + + $"" + + $"" + + $"" + + $"{pos}"; + static string Note(string kind, string text) => + $"" + + SubList(P("0", R($"" + T(" " + text)))) + + $""; + static string Hyperlink(string id, string command) => + $"" + + $"0{command}"; + static string FieldEnd(string id) => $""; + + var secPr = + "" + + "" + + "" + + "" + + "" + + "" + + ""; + var setup = secPr + + ""; + var headerControls = + "" + SubList(P("0", R(T("홀수 쪽 머리말")))) + "" + + ""; + var nestedTable = + "" + + "" + Pos(true, 0, 0) + "" + + "" + Cell(P("0", R(T("안쪽"))), 0, 0, 1, 1, 6000, 1000) + ""; + var table = + "" + + "" + Pos(false, 0, 0, "RIGHT") + + "" + + "" + + SubList(P("0", R(T("표 ") + "" + T(" 캡션")))) + "" + + "" + Cell(P("0", R(T("병합"))), 0, 0, 2, 1, 20000, 1500, "2", header: true, vertAlign: "CENTER") + + Cell(P("0", R(T("세로"))), 2, 0, 1, 2, 10000, 3000, header: true, vertAlign: "BOTTOM") + "" + + "" + Cell(P("0", R(nestedTable + "")), 0, 1, 1, 1, 8000, 1500) + Cell(P("0", R(T("B"))), 1, 1, 1, 1, 12000, 1500) + ""; + var group = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + SubList(P("0", R(T("묶음 글상자"))), "CENTER") + "" + + "" + + "" + + "" + + "" + + "" + Pos(false, 1000, 500) + + ""; + + var section0 = + $"" + + $"{R(setup + headerControls)}{R(T("제1장 개요"))}" + + P("1", R(T("굵게"), "1") + R(T("보통형광 묶음빈칸고정탭둘째 줄" + + "지운 글넣은 글하이픈"))) + + P("0", R("" + Hyperlink("10", "http\\://www.hancom.com\\?a=1;1;0;0;") + T("한컴")) + + R(T("링크") + FieldEnd("10") + T(" 뒤"), "1") + + R(Hyperlink("11", "?처음;0;0;0;") + T("처음으로") + FieldEnd("11") + + "" + T("누름틀") + FieldEnd("12"))) + + P("3", R(T("글머리 항목"))) + + P("4", R(T("번호 항목"))) + + P("5", R(T("줄 간격"))) + + P("0", R(T("표 앞") + table + T("표 뒤"))) + + P("0", R(Picture("image1", 5000, 2500, Pos(false, 1000, unchecked((int)4294967196u)).Replace("vertOffset=\"-100\"", "vertOffset=\"4294967196\"", StringComparison.Ordinal)) + + Picture("image2", 3000, 3000, Pos(true, 0, 0)) + Picture("image9", 3000, 3000, Pos(true, 0, 0)) + "")) + + P("0", R(group + "")) + + P("0", R(T("식: ") + + "" + + "" + Pos(true, 0, 0) + "수식입니다.x over y" + + T(" ") + "" + + "본말덧말")) + + P("0", R(T("각주 참조") + Note("footNote", "각주 내용") + Note("endNote", "미주 내용"))) + + P("0", R("" + + "" + T("차트 뒤"))) + + P("0", R(T("다음 단")), columnBreak: true) + + ""; + + // Other prefixes: a default namespace for the section and "p:" for paragraphs. + var section1 = + "" + + "" + + "" + + "" + + "" + + "둘째 구역"; + + var path = Path.Combine(folder, "hangul-style.hwpx"); + using var zip = ZipFile.Open(path, ZipArchiveMode.Create); + Write(zip, "mimetype", "application/hwp+zip", CompressionLevel.NoCompression); + Write(zip, "version.xml", ""); + Write(zip, "META-INF/container.xml", + "" + + ""); + Write(zip, "META-INF/manifest.xml", ""); + Write(zip, "Contents/content.hpf", + "" + + "한글 문서 제목ko" + + "" + + "" + + "" + + ""); + Write(zip, "Contents/header.xml", "" + header); + Write(zip, "Contents/section0.xml", section0); + Write(zip, "Contents/section1.xml", section1); + foreach (var (name, color) in new[] { ("image1", MagickColors.Coral), ("image2", MagickColors.Teal) }) + { + using var stream = zip.CreateEntry($"BinData/{name}.png").Open(); + stream.Write(Png(color, 40, 20)); + } + return path; + } + + private sealed class FixedPath(string path) : IOutputAllocator + { + public string? Allocate(string extension, string? suffix = null) => path; + } +}