From 7dca5d9d2a5fd1acb508c492c4bfd989cc3b7401 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 1 Oct 2026 07:43:42 +0900 Subject: [PATCH] Read HWPX into the document model without Hancom Office MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HwpxReader is the inverse of HwpxWriter: it reads an .hwpx package into HDocument, so HWPX (and HWP, which rhwp turns into HWPX first) can later be written as DOCX, HTML or EPUB without Hancom Office or LibreOffice. It is not registered as a converter yet; a reader registry picks it up by format id. It reads files from 한글, rhwp's export-hwpx and Filee's own writer: elements are matched by local name, hp:switch takes the exact HwpUnitChar branch (else halves the doubled hp:default / legacy values), and every IDRef is resolved through header.xml. Sections, page setup (NARROWLY = landscape), columns, headers/footers, automatic page numbers, complete paragraph and character shapes, outline headings, numberings and bullets, fields and hyperlinks, bookmarks, notes, tables (spans, widths, heights, borders, fills, header rows, captions, nesting), pictures, shapes, text boxes and groups are kept. Objects without a place in the model are skipped and counted; encrypted and DRM-wrapped files stop with a clear error. Tests write documents from the DOCX, Markdown, XLSX and PPTX readers as HWPX, read them back and compare, then write and read again for an identical model; they also read rhwp's HWPX, the template saved by 한글, a package in the style 한글 writes, and time a 100-page document. Co-Authored-By: Claude Opus 5.5 --- docs/ENGINES.md | 31 + src/Filee.Engines/Hwp/Hwpx/HwpxReader.Body.cs | 586 +++++++++++++ .../Hwp/Hwpx/HwpxReader.Header.cs | 346 ++++++++ .../Hwp/Hwpx/HwpxReader.Objects.cs | 266 ++++++ src/Filee.Engines/Hwp/Hwpx/HwpxReader.cs | 446 ++++++++++ tests/Filee.Engines.Tests/DocxBuilder.cs | 5 + tests/Filee.Engines.Tests/HDocumentAssert.cs | 510 +++++++++++ tests/Filee.Engines.Tests/HwpxReaderTests.cs | 805 ++++++++++++++++++ 8 files changed, 2995 insertions(+) create mode 100644 src/Filee.Engines/Hwp/Hwpx/HwpxReader.Body.cs create mode 100644 src/Filee.Engines/Hwp/Hwpx/HwpxReader.Header.cs create mode 100644 src/Filee.Engines/Hwp/Hwpx/HwpxReader.Objects.cs create mode 100644 src/Filee.Engines/Hwp/Hwpx/HwpxReader.cs create mode 100644 tests/Filee.Engines.Tests/HDocumentAssert.cs create mode 100644 tests/Filee.Engines.Tests/HwpxReaderTests.cs diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 15adb4d..af4d31e 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -92,6 +92,37 @@ text) and LibreOffice + H2Orestart (opens it), see `tests/Filee.Engines.Tests/Do Not converted: Word charts, SmartArt, equations (kept as text), free-form shapes, tracked changes and comments. +## 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/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; + } +}