From e160dd8a3df6a464dc0699a09685cff3ff181350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 29 May 2026 08:41:16 +0200 Subject: [PATCH 01/18] Add PDF logical Markdown export --- Docs/officeimo.pdf.support-matrix.md | 2 +- OfficeIMO.Pdf/README.md | 2 +- .../Reading/Logical/PdfLogicalMarkdown.cs | 288 ++++++++++++++++++ OfficeIMO.Reader/DocumentReader.cs | 45 ++- OfficeIMO.Reader/README.md | 2 +- .../Pdf/PdfLogicalDocumentTests.cs | 64 ++++ OfficeIMO.Tests/Reader.DocumentReader.cs | 18 +- 7 files changed, 408 insertions(+), 13 deletions(-) create mode 100644 OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs diff --git a/Docs/officeimo.pdf.support-matrix.md b/Docs/officeimo.pdf.support-matrix.md index 60874b0b8..01233cefa 100644 --- a/Docs/officeimo.pdf.support-matrix.md +++ b/Docs/officeimo.pdf.support-matrix.md @@ -43,7 +43,7 @@ Status values: | Read | Text extraction | Partial | `PdfReadDocument.ExtractText`, `PdfReadPage.ExtractText`, `PdfTextExtractor.ExtractAllText(byte[]/path/stream)`, `PdfTextExtractor.ExtractAllTextByPageRanges(byte[]/path/stream, PdfPageRange...)`, `PdfTextExtractor.ExtractTextByPage(byte[]/path/stream)`, and `PdfTextExtractor.ExtractTextByPageRanges(byte[]/path/stream, PdfPageRange...)`; byte/path/stream whole-document extraction can write UTF-8 text to output paths or caller-owned streams, selected range extraction can return one concatenated text result or write one text file/stream for wrapper-style `Convert-PdfToText -Pages`, byte-array/path/stream page extraction can write deterministic `source-page-0001.txt` files, and range-list text extraction preserves caller order plus repeated or overlapping selections while writing selected source-page-numbered files with or without layout options | | Read | Text positions/spans | Partial | `PdfReadPage.GetTextSpans()` returns generated standard-font spans with glyph-width-based advances when `/Widths` is omitted, including common WinAnsi punctuation and accented Latin letters | | Read | Image extraction | Partial | `PdfImageExtractor.ExtractImages(byte[]/path/stream/document)`, `ExtractImagesByPageRanges(byte[]/path/stream/document, PdfPageRange...)`, and `PdfReadDocument.ExtractImages()` return page image XObjects; byte-array, path, and stream extraction can also write deterministic `source-page-0001-image-0001.png` files for all pages or selected source-page ranges, while range-list image extraction preserves caller order and deduplicates overlapping selections; JPEG images are returned as JPEG files and simple PNG-predictor Flate images as PNG files, including compatible grayscale/RGB Flate images with grayscale `/SMask` alpha as gray-alpha/RGBA PNGs | -| Read | Logical object model | Partial | `PdfLogicalDocument.Load(byte[]/path/stream, PdfTextLayoutOptions?)`, `LoadPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `From(PdfReadDocument, ...)`, and `FromPageRanges(PdfReadDocument, options, PdfPageRange...)` expose one wrapper-friendly read surface with metadata, selected source pages in caller order when ranges are used, `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)` helpers that preserve range-selection duplicates, document-level `TextBlocks`, `Headings`, `Paragraphs`, `ListItems`, `Tables`, and `Images`, flattened logical `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on both documents and pages, line-level text blocks, heuristic headings, list item objects with marker/level/text hints, heuristic paragraph groups, leader rows, detected tables with row/column/cell objects, image XObjects, URI/named-destination link annotation objects with document-level `Links`, `LinksByUri`, `LinksByDestinationName`, `GetLinksByUri(...)`, and `GetLinksByDestinationName(...)`, page-level AcroForm widget objects with current `/AS` and named `/AP /N` normal appearance states, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags`, named signature flag helpers, and `/DA` metadata, simple AcroForm fields with typed `PdfFormFieldKind`, inherited common `/Ff` flag helpers, scalar and array current/default values, selected/default-selected choice-option matching, inherited text `/MaxLen`, inherited AcroForm/field-tree `/DA` default appearance strings, inherited `/Q` text alignment, inherited simple choice `/Opt` options, distinct field page-number helpers, field-local widget page lookups, named, kind-based, and page-number form-field lookup helpers, document-level `FormWidgets`, `FormWidgetsByFieldName`, `FormWidgetsByPageNumber`, `GetFormWidgets(string)`, `GetFormWidgets(int)`, and simple form-widget page/rectangle objects. Range-based logical loads filter page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages while preserving duplicate selected page widgets in caller order. The two-page line-item statement fixture now guards source-page ordering, table readback, totals readback, and selected range ordering through the logical model. This is the first AST-style surface for PSWriteOffice-style workflows, but heading/paragraph/table/list detection remains heuristic rather than a full tagged-PDF or Word-like semantic reconstruction | +| Read | Logical object model | Partial | `PdfLogicalDocument.Load(byte[]/path/stream, PdfTextLayoutOptions?)`, `LoadPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `From(PdfReadDocument, ...)`, and `FromPageRanges(PdfReadDocument, options, PdfPageRange...)` expose one wrapper-friendly read surface with metadata, selected source pages in caller order when ranges are used, `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)` helpers that preserve range-selection duplicates, document-level `TextBlocks`, `Headings`, `Paragraphs`, `ListItems`, `Tables`, and `Images`, flattened logical `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on both documents and pages, line-level text blocks, heuristic headings, list item objects with marker/level/text hints, heuristic paragraph groups, leader rows, detected tables with row/column/cell objects, image XObjects, URI/named-destination link annotation objects with document-level `Links`, `LinksByUri`, `LinksByDestinationName`, `GetLinksByUri(...)`, and `GetLinksByDestinationName(...)`, page-level AcroForm widget objects with current `/AS` and named `/AP /N` normal appearance states, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags`, named signature flag helpers, and `/DA` metadata, simple AcroForm fields with typed `PdfFormFieldKind`, inherited common `/Ff` flag helpers, scalar and array current/default values, selected/default-selected choice-option matching, inherited text `/MaxLen`, inherited AcroForm/field-tree `/DA` default appearance strings, inherited `/Q` text alignment, inherited simple choice `/Opt` options, distinct field page-number helpers, field-local widget page lookups, named, kind-based, and page-number form-field lookup helpers, document-level `FormWidgets`, `FormWidgetsByFieldName`, `FormWidgetsByPageNumber`, `GetFormWidgets(string)`, `GetFormWidgets(int)`, and simple form-widget page/rectangle objects. `PdfLogicalDocument.ToMarkdown(...)` and `PdfLogicalPage.ToMarkdown(...)` render the same logical model as Markdown with headings, paragraphs, lists, detected tables, image placeholders, and optional link/form annotations. Range-based logical loads filter page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages while preserving duplicate selected page widgets in caller order. The two-page line-item statement fixture now guards source-page ordering, table readback, totals readback, and selected range ordering through the logical model. This is the first AST-style surface for PSWriteOffice-style workflows, but heading/paragraph/table/list detection remains heuristic rather than a full tagged-PDF or Word-like semantic reconstruction | | Read | Simple structure extraction | Partial | `PdfReadPage.ExtractStructured(...)`, `PdfReadDocument.ExtractStructuredPages(...)`, `PdfReadDocument.ExtractHeadingsByPage(...)`, `PdfReadDocument.ExtractListItemsByPage(...)`, `PdfReadDocument.ExtractParagraphsByPage(...)`, `PdfTextExtractor.ExtractStructuredByPage(byte[]/path/stream, options)`, `ExtractStructuredByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractHeadingsByPage(byte[]/path/stream, options)`, `ExtractHeadingsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractListItemsByPage(byte[]/path/stream, options)`, `ExtractListItemsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractParagraphsByPage(byte[]/path/stream, options)`, `ExtractParagraphsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `PdfTextExtractor.ExtractTablesByPage(byte[]/path/stream, options)`, and `ExtractTablesByPageRanges(byte[]/path/stream, options, PdfPageRange...)` expose column-aware text, heuristic headings, heuristic paragraph groups, list item marker/level hints, dot/hyphen/underscore leader rows that preserve decimal/currency value punctuation, and heuristic table rows/geometry for wrapper-friendly readback while preserving selected source page numbers for heading/list-item/paragraph/table results; `PdfTextExtractor.ExtractTablesByPage(pdfBytes, outputDirectory, baseName, options)`, `ExtractTablesByPage(inputPath, outputDirectory, options)`, `ExtractTablesByPage(stream, outputDirectory, baseName, options)`, and matching `ExtractTablesByPageRanges(...)` overloads write deterministic escaped CSV files per detected table for all pages or selected source-page ranges, including the two-page line-item statement fixture with selected source-page order, line-item rows, and totals guarded for wrapper use | | Manipulate | Split by page range | Partial | `PdfPageExtractor.ExtractPageRange(byte[]/path/stream, firstPage, lastPage)`, `ExtractPageRange(..., PdfPageRange)`, `ExtractPageRanges(..., PdfPageRange...)`, `SplitPages(byte[]/path/stream)`, and `SplitPageRanges(..., PdfPageRange...)` return bytes for wrapper pipelines; `PdfPageRange.Parse(...)`, `TryParse(...)`, `ParseMany("1-3,5")`, and `TryParseMany(...)` parse one-based single pages plus inclusive `first-last` / `first..last` range lists while preserving caller order; path and stream split helpers can also write deterministic `source-page-0001.pdf` and `source-pages-0001-0003.pdf` files; simple direct catalog `/PageMode`, `/PageLayout`, `/Version`, `/Lang`, simple direct `/PageLabels` number trees, simple outlines including simple GoTo action outline entries whose destinations point only at copied pages, direct `/Dests` dictionaries, simple `/Names` `/Dests` name trees including leaf `/Kids`, destination-array `/OpenAction` entries, simple GoTo open-action dictionaries, simple `/ViewerPreferences` dictionaries, simple catalog `/Metadata` XMP XML streams, simple catalog `/URI` base dictionaries, simple `/OutputIntents` metadata graphs, simple `/Names` `/EmbeddedFiles` attachment trees, simple catalog `/AF` associated-file arrays, and simple `/OCProperties` optional-content metadata are preserved, with copied-page labels reindexed, stale destinations/open actions pruned, stale outline trees/name-tree destinations dropped, and stale named-destination link annotations removed when their target pages are not copied; the two-page line-item statement fixture now guards split/extract readback through the logical model; currently scoped to PDFs handled by the OfficeIMO parser | | Manipulate | Merge PDFs | Partial | `PdfMerger.Merge(byte[]/stream inputs)` and `PdfMerger.MergeFilesToBytes(path inputs)` can return bytes or write to output streams, while `PdfMerger.MergeFiles(...)` writes merged files from `params` paths or enumerable path lists and can write enumerable file-list inputs to output streams for wrapper pipelines; simple direct catalog `/PageMode`, `/PageLayout`, `/Version`, `/Lang`, simple direct `/PageLabels` number trees, simple outline trees including simple GoTo action outline entries, direct `/Dests` dictionaries, simple `/Names` `/Dests` name trees, destination-array `/OpenAction` entries, simple GoTo open-action dictionaries, simple `/ViewerPreferences` dictionaries, simple catalog `/Metadata` XMP XML streams, simple catalog `/URI` base dictionaries, simple `/OutputIntents` metadata graphs, simple `/Names` `/EmbeddedFiles` attachment trees, simple catalog `/AF` associated-file arrays, and simple `/OCProperties` optional-content metadata are preserved from the first source; the two-page line-item statement fixture now guards merge-after-split readback through the logical model; currently scoped to parser-supported PDFs | diff --git a/OfficeIMO.Pdf/README.md b/OfficeIMO.Pdf/README.md index 5c28d42bf..d5840b613 100644 --- a/OfficeIMO.Pdf/README.md +++ b/OfficeIMO.Pdf/README.md @@ -89,7 +89,7 @@ Reading: - Inspect page count, selected source page ranges, page sizes, orientation, inherited page rotation, catalog page mode/layout/version/language values, simple page-label rules, simple document open-action targets, simple viewer preference entries, simple AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, field names/types/kinds/common flags/text max lengths/default appearance strings/text alignment/choice options/scalar or array values/selected options/field page numbers/field-local widget page lookups/widget field names, geometry, and named annotation flags, form field name/kind/page-number lookup helpers, document-level and page-level form widget list/name/page-number lookup helpers, simple page URI and named-destination link annotation summaries, distinct document-level link URI and internal destination targets, document-level page-aware link lists, named destination names/targets, and per-page link annotations with contents metadata through `PdfInspector`; `InspectPageRanges(...)` preserves caller range order and overlaps while narrowing page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages. - Extract document text, selected page-range text, and page-by-page text from bytes, paths, or streams; helpers can write one UTF-8 text result, or per-page text files, for wrapper pipelines. - Extract text spans with positions. -- Build an initial logical read model through `PdfLogicalDocument.Load(...)` / `From(...)`, exposing logical pages, source-page lookup helpers through `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)`, document-level typed collections for text blocks, headings, paragraphs, list items, tables, and images, generic `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on documents and pages, URI/named-destination link annotation objects with document-level URI/destination lookup, page-level AcroForm widget objects, metadata, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, and simple AcroForm fields with typed field-kind/common-flag helpers, text max length, inherited AcroForm/field-tree default appearance strings, text alignment, choice options, scalar or array values, selected options, distinct field page numbers, field-local widget page lookups, named/kind/page lookup, and document/page-level widget lookup helpers by field name or page number so wrappers can start from one stable object surface instead of stitching together low-level extraction helpers; `LoadPageRanges(...)` and `FromPageRanges(...)` return logical objects for selected source page ranges while preserving caller order and overlaps, and range-based logical loads now expose only page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets represented on selected source pages. +- Build an initial logical read model through `PdfLogicalDocument.Load(...)` / `From(...)`, exposing logical pages, source-page lookup helpers through `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)`, document-level typed collections for text blocks, headings, paragraphs, list items, tables, and images, generic `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on documents and pages, URI/named-destination link annotation objects with document-level URI/destination lookup, page-level AcroForm widget objects, metadata, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, and simple AcroForm fields with typed field-kind/common-flag helpers, text max length, inherited AcroForm/field-tree default appearance strings, text alignment, choice options, scalar or array values, selected options, distinct field page numbers, field-local widget page lookups, named/kind/page lookup, and document/page-level widget lookup helpers by field name or page number so wrappers can start from one stable object surface instead of stitching together low-level extraction helpers; `LoadPageRanges(...)` and `FromPageRanges(...)` return logical objects for selected source page ranges while preserving caller order and overlaps, and range-based logical loads now expose only page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets represented on selected source pages. `PdfLogicalDocument.ToMarkdown(...)` and `PdfLogicalPage.ToMarkdown(...)` render that same logical model as wrapper-friendly Markdown for headings, paragraphs, lists, detected tables, images, and optional link/form annotations without adding a second structure model. - Extract page image XObjects from bytes, paths, streams, or parsed documents with `PdfImageExtractor`; `ExtractImagesByPageRanges(..., PdfPageRange...)` selects reusable page-range lists for wrapper pipelines, JPEG images are returned as JPEG files and simple PNG-predictor Flate images as PNG files, compatible grayscale/RGB Flate images with grayscale `/SMask` alpha are returned as gray-alpha/RGBA PNGs, and helpers can write extracted images to deterministic page-numbered files. - Heuristic column-aware text extraction and simple structured extraction; `PdfTextExtractor` exposes layout-option overloads for bytes, paths, streams, page-range text/structured/heading/list-item/paragraph/table extraction with `PdfPageRange` lists, byte/path/stream whole-document text output to UTF-8 paths or caller-owned streams, and page-file output, plus structured-by-page, heading-by-page, list-item-by-page, paragraph-by-page, and table-by-page extraction that preserves detected lines, heuristic headings, heuristic paragraph groups, list item marker/level hints, dot/hyphen/underscore leader rows with decimal/currency value punctuation, simple table geometry, and selected source page numbers so wrappers can request readback without dropping to `PdfReadDocument`. Byte-, path-, and stream-based text/table extraction can also write deterministic `source-page-0001.txt` and `source-page-0001-table-0001.csv` files for all pages or selected page ranges, including option-aware selected text page output and the two-page line-item statement fixture's selected table output, with CSV escaping for table output. - Decode common simple streams used by many PDFs, including uncompressed, Flate, ASCIIHex, ASCII85, RunLength, and LZW paths. diff --git a/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs b/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs new file mode 100644 index 000000000..d8c3a4453 --- /dev/null +++ b/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs @@ -0,0 +1,288 @@ +namespace OfficeIMO.Pdf; + +/// +/// Options for rendering a logical PDF read model as Markdown. +/// +public sealed class PdfLogicalMarkdownOptions { + /// Emit horizontal-rule separators between logical pages. + public bool IncludePageSeparators { get; set; } = true; + + /// Emit readable placeholders for image elements discovered in the logical model. + public bool IncludeImagePlaceholders { get; set; } = true; + + /// Emit a link annotation section for supported URI and named-destination links. + public bool IncludeLinkAnnotations { get; set; } + + /// Emit a form widget section for AcroForm widgets placed on pages. + public bool IncludeFormWidgets { get; set; } + + /// Markdown text used between pages when is true. + public string PageSeparator { get; set; } = "---"; +} + +/// +/// Markdown rendering helpers for the first-party logical PDF read model. +/// +public static class PdfLogicalMarkdownExtensions { + /// + /// Renders the logical PDF document as Markdown using headings, paragraphs, lists, tables, and optional annotations from the existing logical model. + /// + public static string ToMarkdown(this PdfLogicalDocument document, PdfLogicalMarkdownOptions? options = null) { + Guard.NotNull(document, nameof(document)); + options ??= new PdfLogicalMarkdownOptions(); + + var builder = new StringBuilder(); + for (int i = 0; i < document.Pages.Count; i++) { + if (i > 0 && options.IncludePageSeparators) { + AppendBlock(builder, options.PageSeparator); + } + + AppendPage(builder, document.Pages[i], options); + } + + return builder.ToString().TrimEnd(); + } + + /// + /// Renders a logical PDF page as Markdown using headings, paragraphs, lists, tables, and optional annotations from the existing logical model. + /// + public static string ToMarkdown(this PdfLogicalPage page, PdfLogicalMarkdownOptions? options = null) { + Guard.NotNull(page, nameof(page)); + options ??= new PdfLogicalMarkdownOptions(); + + var builder = new StringBuilder(); + AppendPage(builder, page, options); + return builder.ToString().TrimEnd(); + } + + private static void AppendPage(StringBuilder builder, PdfLogicalPage page, PdfLogicalMarkdownOptions options) { + List items = BuildPageItems(page, options); + items.Sort(CompareMarkdownItems); + + for (int i = 0; i < items.Count; i++) { + AppendBlock(builder, items[i].Markdown); + } + } + + private static List BuildPageItems(PdfLogicalPage page, PdfLogicalMarkdownOptions options) { + var items = new List(); + int sequence = 0; + + for (int i = 0; i < page.Headings.Count; i++) { + PdfLogicalHeading heading = page.Headings[i]; + int level = Math.Min(Math.Max(heading.Level, 1), 6); + items.Add(new MarkdownItem(heading.Line.BaselineY, heading.Line.XStart, sequence++, new string('#', level) + " " + EscapeInline(heading.Text))); + } + + for (int i = 0; i < page.Paragraphs.Count; i++) { + PdfLogicalParagraph paragraph = page.Paragraphs[i]; + items.Add(new MarkdownItem(paragraph.YTop, paragraph.XStart, sequence++, EscapeInline(paragraph.Text))); + } + + for (int i = 0; i < page.ListItems.Count; i++) { + PdfLogicalListItem listItem = page.ListItems[i]; + string indent = new string(' ', Math.Max(listItem.Level - 1, 0) * 2); + items.Add(new MarkdownItem(listItem.Line.BaselineY, listItem.Line.XStart, sequence++, indent + FormatListMarker(listItem.Marker) + " " + EscapeInline(listItem.Text))); + } + + for (int i = 0; i < page.Tables.Count; i++) { + PdfLogicalTable table = page.Tables[i]; + double x = table.Columns.Count > 0 ? table.Columns[0].From : 0; + string markdown = RenderTable(table); + if (markdown.Length > 0) { + items.Add(new MarkdownItem(table.YTop, x, sequence++, markdown)); + } + } + + IReadOnlyList leaderRows = page.GetElements(PdfLogicalElementKind.LeaderRow); + for (int i = 0; i < leaderRows.Count; i++) { + if (leaderRows[i] is PdfLogicalLeaderRow leaderRow) { + items.Add(new MarkdownItem(null, 0, sequence++, EscapeInline(leaderRow.Label) + " | " + EscapeInline(leaderRow.Value))); + } + } + + if (options.IncludeImagePlaceholders) { + for (int i = 0; i < page.Images.Count; i++) { + PdfLogicalImage image = page.Images[i]; + string description = "[Image: page " + image.PageNumber + ", resource " + EscapeInline(image.ResourceName) + ", " + image.Width + "x" + image.Height; + string? mimeType = image.MimeType; + if (!string.IsNullOrEmpty(mimeType)) { + description += ", " + EscapeInline(mimeType!); + } + + description += "]"; + items.Add(new MarkdownItem(null, 0, sequence++, description)); + } + } + + if (options.IncludeLinkAnnotations) { + for (int i = 0; i < page.Links.Count; i++) { + PdfLogicalLinkAnnotation link = page.Links[i]; + string target = link.Uri ?? link.DestinationName ?? string.Empty; + if (target.Length == 0) { + continue; + } + + string label = !string.IsNullOrWhiteSpace(link.Contents) ? link.Contents! : target; + string markdown = link.Uri is not null + ? "[Link: " + EscapeInline(label) + "](" + EscapeLinkTarget(link.Uri) + ")" + : "[Link: " + EscapeInline(label) + " -> " + EscapeInline(target) + "]"; + items.Add(new MarkdownItem(link.Y2, link.X1, sequence++, markdown)); + } + } + + if (options.IncludeFormWidgets) { + for (int i = 0; i < page.FormWidgets.Count; i++) { + PdfLogicalFormWidget widget = page.FormWidgets[i]; + string name = widget.FieldName ?? widget.FieldType ?? "Field"; + string value = widget.Value ?? string.Empty; + items.Add(new MarkdownItem(widget.Y2, widget.X1, sequence++, "[Form field: " + EscapeInline(name) + (value.Length > 0 ? " = " + EscapeInline(value) : string.Empty) + "]")); + } + } + + return items; + } + + private static int CompareMarkdownItems(MarkdownItem left, MarkdownItem right) { + bool leftHasY = left.Y.HasValue; + bool rightHasY = right.Y.HasValue; + if (leftHasY && rightHasY) { + int yComparison = right.Y!.Value.CompareTo(left.Y!.Value); + if (yComparison != 0) { + return yComparison; + } + + int xComparison = left.X.CompareTo(right.X); + if (xComparison != 0) { + return xComparison; + } + } else if (leftHasY != rightHasY) { + return leftHasY ? -1 : 1; + } + + return left.Sequence.CompareTo(right.Sequence); + } + + private static void AppendBlock(StringBuilder builder, string markdown) { + if (string.IsNullOrWhiteSpace(markdown)) { + return; + } + + if (builder.Length > 0) { + builder.AppendLine(); + builder.AppendLine(); + } + + builder.Append(markdown.Trim()); + } + + private static string RenderTable(PdfLogicalTable table) { + if (table.Rows.Count == 0) { + return string.Empty; + } + + int columnCount = 0; + for (int i = 0; i < table.Rows.Count; i++) { + columnCount = Math.Max(columnCount, table.Rows[i].Count); + } + + if (columnCount == 0) { + return string.Empty; + } + + var builder = new StringBuilder(); + AppendTableRow(builder, table.Rows[0], columnCount); + builder.AppendLine(); + AppendTableSeparator(builder, columnCount); + + for (int i = 1; i < table.Rows.Count; i++) { + builder.AppendLine(); + AppendTableRow(builder, table.Rows[i], columnCount); + } + + return builder.ToString(); + } + + private static void AppendTableRow(StringBuilder builder, IReadOnlyList row, int columnCount) { + builder.Append('|'); + for (int i = 0; i < columnCount; i++) { + string cell = i < row.Count ? row[i] : string.Empty; + builder.Append(' '); + builder.Append(EscapeTableCell(cell)); + builder.Append(" |"); + } + } + + private static void AppendTableSeparator(StringBuilder builder, int columnCount) { + builder.Append('|'); + for (int i = 0; i < columnCount; i++) { + builder.Append(" --- |"); + } + } + + private static string FormatListMarker(string marker) { + string trimmed = marker.Trim(); + if (trimmed.Length == 0) { + return "-"; + } + + if (IsNumericMarker(trimmed)) { + char last = trimmed[trimmed.Length - 1]; + return last == '.' || last == ')' + ? trimmed + : trimmed + "."; + } + + return "-"; + } + + private static bool IsNumericMarker(string marker) { + int digitCount = 0; + for (int i = 0; i < marker.Length; i++) { + char ch = marker[i]; + if (char.IsDigit(ch)) { + digitCount++; + continue; + } + + if (ch != '.' && ch != ')') { + return false; + } + } + + return digitCount > 0; + } + + private static string EscapeInline(string text) { + if (string.IsNullOrEmpty(text)) { + return string.Empty; + } + + return text.Replace("\r", " ").Replace("\n", " ").Trim(); + } + + private static string EscapeTableCell(string text) { + return EscapeInline(text).Replace("|", "\\|"); + } + + private static string EscapeLinkTarget(string uri) { + return uri.Replace(")", "%29"); + } + + private sealed class MarkdownItem { + public MarkdownItem(double? y, double x, int sequence, string markdown) { + Y = y; + X = x; + Sequence = sequence; + Markdown = markdown; + } + + public double? Y { get; } + + public double X { get; } + + public int Sequence { get; } + + public string Markdown { get; } + } +} diff --git a/OfficeIMO.Reader/DocumentReader.cs b/OfficeIMO.Reader/DocumentReader.cs index ddd848346..a40e311d3 100644 --- a/OfficeIMO.Reader/DocumentReader.cs +++ b/OfficeIMO.Reader/DocumentReader.cs @@ -81,7 +81,7 @@ public static class DocumentReader { new ReaderHandlerCapability { Id = "officeimo.reader.pdf", DisplayName = "PDF Reader", - Description = "Built-in PDF page extractor.", + Description = "Built-in PDF logical page and markdown extractor.", Kind = ReaderInputKind.Pdf, Extensions = new[] { ".pdf" }, IsBuiltIn = true, @@ -1248,21 +1248,23 @@ private static IEnumerable ReadPowerPoint(Stream stream, string? so private static IEnumerable ReadPdf(string path, ReaderOptions opt, CancellationToken ct) { var fileName = Path.GetFileName(path); - var doc = PdfReadDocument.Load(path); + var doc = PdfLogicalDocument.Load(path); int outIndex = 0; for (int pageIndex = 0; pageIndex < doc.Pages.Count; pageIndex++) { ct.ThrowIfCancellationRequested(); - var pageNumber = pageIndex + 1; - var pageText = doc.Pages[pageIndex].ExtractText(); + var page = doc.Pages[pageIndex]; + var pageNumber = page.PageNumber; + var pageText = BuildPdfPageText(page); if (string.IsNullOrWhiteSpace(pageText)) { yield return BuildPdfEmptyChunk(path, fileName, pageNumber, outIndex); outIndex++; continue; } - var pageChunks = ChunkPdfText(path, fileName, pageNumber, pageText, opt, outIndex, ct, out var nextIndex); + string pageMarkdown = page.ToMarkdown(); + var pageChunks = ChunkPdfText(path, fileName, pageNumber, pageText, pageMarkdown, opt, outIndex, ct, out var nextIndex); outIndex = nextIndex; foreach (var chunk in pageChunks) { yield return chunk; @@ -1273,21 +1275,23 @@ private static IEnumerable ReadPdf(string path, ReaderOptions opt, private static IEnumerable ReadPdf(Stream stream, string? sourceName, ReaderOptions opt, CancellationToken ct) { using var ms = CopyToMemory(stream, ct); var fileName = string.IsNullOrWhiteSpace(sourceName) ? "memory.pdf" : Path.GetFileName(sourceName!.Trim()); - var doc = PdfReadDocument.Load(ms.ToArray()); + var doc = PdfLogicalDocument.Load(ms.ToArray()); int outIndex = 0; for (int pageIndex = 0; pageIndex < doc.Pages.Count; pageIndex++) { ct.ThrowIfCancellationRequested(); - var pageNumber = pageIndex + 1; - var pageText = doc.Pages[pageIndex].ExtractText(); + var page = doc.Pages[pageIndex]; + var pageNumber = page.PageNumber; + var pageText = BuildPdfPageText(page); if (string.IsNullOrWhiteSpace(pageText)) { yield return BuildPdfEmptyChunk(sourceName ?? fileName, fileName, pageNumber, outIndex); outIndex++; continue; } - var pageChunks = ChunkPdfText(sourceName ?? fileName, fileName, pageNumber, pageText, opt, outIndex, ct, out var nextIndex); + string pageMarkdown = page.ToMarkdown(); + var pageChunks = ChunkPdfText(sourceName ?? fileName, fileName, pageNumber, pageText, pageMarkdown, opt, outIndex, ct, out var nextIndex); outIndex = nextIndex; foreach (var chunk in pageChunks) { yield return chunk; @@ -1584,6 +1588,7 @@ private static List ChunkPdfText( string fileName, int pageNumber, string text, + string? markdown, ReaderOptions opt, int startChunkIndex, CancellationToken ct, @@ -1627,10 +1632,32 @@ private static List ChunkPdfText( list.Add(BuildPdfChunk(path, fileName, pageNumber, outIndex, firstLine, current.ToString().TrimEnd(), warnings)); outIndex++; } + + if (!string.IsNullOrWhiteSpace(markdown) && list.Count == 1 && markdown!.Length <= opt.MaxChars) { + list[0].Markdown = markdown.Trim(); + } + nextChunkIndex = outIndex; return list; } + private static string BuildPdfPageText(PdfLogicalPage page) { + if (page.TextBlocks.Count == 0) { + return string.Empty; + } + + var builder = new StringBuilder(); + for (int i = 0; i < page.TextBlocks.Count; i++) { + if (i > 0) { + builder.AppendLine(); + } + + builder.Append(page.TextBlocks[i].Text); + } + + return builder.ToString(); + } + private static ReaderChunk BuildMarkdownChunk( string path, string fileName, diff --git a/OfficeIMO.Reader/README.md b/OfficeIMO.Reader/README.md index 752c6208a..377e80030 100644 --- a/OfficeIMO.Reader/README.md +++ b/OfficeIMO.Reader/README.md @@ -5,7 +5,7 @@ - Excel (`.xlsx`, `.xlsm`) -> table chunks + optional Markdown table previews - PowerPoint (`.pptx`, `.pptm`) -> slide-aligned Markdown chunks (optionally including notes) - Markdown (`.md`, `.markdown`) -> parser-aware heading chunks with preserved fenced/table blocks -- PDF (`.pdf`) -> page-aware text chunks +- PDF (`.pdf`) -> page-aware text chunks with logical Markdown when a page fits in one chunk The goal is to make it easy for tools like chat bots to ingest content deterministically. diff --git a/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs b/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs index 5d025eeed..8035050ac 100644 --- a/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs @@ -107,6 +107,60 @@ cell.Column is not null && Assert.Contains(logical.Elements, element => element.Kind == PdfLogicalElementKind.Image); } + [Fact] + public void ToMarkdown_RendersLogicalHeadingsParagraphsListsTablesAndImages() { + byte[] pdf = PdfDoc.Create(new PdfOptions { + PageWidth = 420, + PageHeight = 360, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36, + DefaultFontSize = 10 + }) + .H1("Logical Heading") + .Paragraph(p => p.Text("Logical readback marker.")) + .Bullets(new[] { "Detected logical bullet" }) + .Table(new[] { + new[] { "Code", "Name", "Qty" }, + new[] { "A-100", "Alpha", "2" }, + new[] { "B-200", "Beta", "14" } + }, style: new PdfTableStyle { + ColumnWidthPoints = new List { 70, 170, 60 }, + HeaderRowCount = 1, + CellPaddingX = 6, + CellPaddingY = 4 + }) + .Image(CreateMinimalRgbPng(), 18, 18) + .ToBytes(); + + PdfLogicalDocument logical = PdfLogicalDocument.Load(pdf, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + + string markdown = logical.ToMarkdown(); + string normalizedMarkdown = Normalize(markdown); + + Assert.Contains("# Logical Heading", markdown, StringComparison.Ordinal); + Assert.Contains("Logicalreadbackmarker.", normalizedMarkdown, StringComparison.Ordinal); + Assert.Contains("-Detectedlogicalbullet", normalizedMarkdown, StringComparison.Ordinal); + Assert.Contains("| Code | Name | Qty |", markdown, StringComparison.Ordinal); + Assert.Contains("| --- | --- | --- |", markdown, StringComparison.Ordinal); + Assert.Contains("| A-100 | Alpha | 2 |", markdown, StringComparison.Ordinal); + Assert.Contains("[Image: page 1", markdown, StringComparison.Ordinal); + AssertContainsInOrder(normalizedMarkdown, + "#LogicalHeading", + "Logicalreadbackmarker.", + "-Detectedlogicalbullet", + "|Code|Name|Qty|", + "[Image:page1"); + + string withoutImages = logical.ToMarkdown(new PdfLogicalMarkdownOptions { + IncludeImagePlaceholders = false + }); + Assert.DoesNotContain("[Image:", withoutImages, StringComparison.Ordinal); + } + [Fact] public void LoadPageRanges_BuildsLogicalModelForSelectedSourcePagesInCallerOrder() { byte[] pdf = BuildThreePageLogicalPdf(); @@ -648,6 +702,16 @@ private static bool RowContains(IReadOnlyList row, params string[] expec return expectedTokens.All(token => rowText.Contains(token, StringComparison.Ordinal)); } + private static void AssertContainsInOrder(string text, params string[] expectedTokens) { + int lastIndex = -1; + for (int i = 0; i < expectedTokens.Length; i++) { + int index = text.IndexOf(expectedTokens[i], StringComparison.Ordinal); + Assert.True(index >= 0, $"Expected token '{expectedTokens[i]}' was not found."); + Assert.True(index > lastIndex, $"Expected token '{expectedTokens[i]}' to appear after the previous token."); + lastIndex = index; + } + } + private static byte[] BuildThreePageLogicalPdf() { return PdfDoc.Create(new PdfOptions { PageWidth = 260, diff --git a/OfficeIMO.Tests/Reader.DocumentReader.cs b/OfficeIMO.Tests/Reader.DocumentReader.cs index 43a9fc9b1..6447f4e7c 100644 --- a/OfficeIMO.Tests/Reader.DocumentReader.cs +++ b/OfficeIMO.Tests/Reader.DocumentReader.cs @@ -563,7 +563,15 @@ public void DocumentReader_MarkdownChunking_AssignsDeterministicSlugsForNonAscii public void DocumentReader_CanReadPdf() { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".pdf"); try { - var pdf = PdfDoc.Create(); + var pdf = PdfDoc.Create(new PdfOptions { + PageWidth = 420, + PageHeight = 360, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36, + DefaultFontSize = 10 + }); pdf.H1("PDF Title"); pdf.Paragraph(p => p.Text("This is a PDF body.")); pdf.Save(path); @@ -572,6 +580,10 @@ public void DocumentReader_CanReadPdf() { Assert.NotEmpty(chunks); Assert.Contains(chunks, c => c.Kind == ReaderInputKind.Pdf); Assert.Contains(chunks, c => c.Location.Page.HasValue && c.Location.Page.Value >= 1); + var pdfChunk = Assert.Single(chunks, c => c.Kind == ReaderInputKind.Pdf); + Assert.NotNull(pdfChunk.Markdown); + Assert.Contains("# PDF Title", pdfChunk.Markdown, StringComparison.Ordinal); + Assert.Contains("ThisisaPDFbody.", NormalizeWhitespace(pdfChunk.Markdown!), StringComparison.Ordinal); } finally { if (File.Exists(path)) File.Delete(path); } @@ -1413,4 +1425,8 @@ public void DocumentReader_ReadFolderDetailed_IncludeChunksTrue_KeepsSummaryChun if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } } + + private static string NormalizeWhitespace(string text) { + return new string(text.Where(ch => !char.IsWhiteSpace(ch)).ToArray()); + } } From 2fc3f8770a1b52c7da758be7d55e29df908f4c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 29 May 2026 08:41:16 +0200 Subject: [PATCH 02/18] Add PDF logical Markdown export --- Docs/officeimo.pdf.support-matrix.md | 2 +- OfficeIMO.Pdf/README.md | 2 +- .../Reading/Logical/PdfLogicalMarkdown.cs | 288 ++++++++++++++++++ OfficeIMO.Reader/DocumentReader.cs | 45 ++- OfficeIMO.Reader/README.md | 2 +- .../Pdf/PdfLogicalDocumentTests.cs | 64 ++++ OfficeIMO.Tests/Reader.DocumentReader.cs | 18 +- 7 files changed, 408 insertions(+), 13 deletions(-) create mode 100644 OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs diff --git a/Docs/officeimo.pdf.support-matrix.md b/Docs/officeimo.pdf.support-matrix.md index 60874b0b8..01233cefa 100644 --- a/Docs/officeimo.pdf.support-matrix.md +++ b/Docs/officeimo.pdf.support-matrix.md @@ -43,7 +43,7 @@ Status values: | Read | Text extraction | Partial | `PdfReadDocument.ExtractText`, `PdfReadPage.ExtractText`, `PdfTextExtractor.ExtractAllText(byte[]/path/stream)`, `PdfTextExtractor.ExtractAllTextByPageRanges(byte[]/path/stream, PdfPageRange...)`, `PdfTextExtractor.ExtractTextByPage(byte[]/path/stream)`, and `PdfTextExtractor.ExtractTextByPageRanges(byte[]/path/stream, PdfPageRange...)`; byte/path/stream whole-document extraction can write UTF-8 text to output paths or caller-owned streams, selected range extraction can return one concatenated text result or write one text file/stream for wrapper-style `Convert-PdfToText -Pages`, byte-array/path/stream page extraction can write deterministic `source-page-0001.txt` files, and range-list text extraction preserves caller order plus repeated or overlapping selections while writing selected source-page-numbered files with or without layout options | | Read | Text positions/spans | Partial | `PdfReadPage.GetTextSpans()` returns generated standard-font spans with glyph-width-based advances when `/Widths` is omitted, including common WinAnsi punctuation and accented Latin letters | | Read | Image extraction | Partial | `PdfImageExtractor.ExtractImages(byte[]/path/stream/document)`, `ExtractImagesByPageRanges(byte[]/path/stream/document, PdfPageRange...)`, and `PdfReadDocument.ExtractImages()` return page image XObjects; byte-array, path, and stream extraction can also write deterministic `source-page-0001-image-0001.png` files for all pages or selected source-page ranges, while range-list image extraction preserves caller order and deduplicates overlapping selections; JPEG images are returned as JPEG files and simple PNG-predictor Flate images as PNG files, including compatible grayscale/RGB Flate images with grayscale `/SMask` alpha as gray-alpha/RGBA PNGs | -| Read | Logical object model | Partial | `PdfLogicalDocument.Load(byte[]/path/stream, PdfTextLayoutOptions?)`, `LoadPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `From(PdfReadDocument, ...)`, and `FromPageRanges(PdfReadDocument, options, PdfPageRange...)` expose one wrapper-friendly read surface with metadata, selected source pages in caller order when ranges are used, `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)` helpers that preserve range-selection duplicates, document-level `TextBlocks`, `Headings`, `Paragraphs`, `ListItems`, `Tables`, and `Images`, flattened logical `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on both documents and pages, line-level text blocks, heuristic headings, list item objects with marker/level/text hints, heuristic paragraph groups, leader rows, detected tables with row/column/cell objects, image XObjects, URI/named-destination link annotation objects with document-level `Links`, `LinksByUri`, `LinksByDestinationName`, `GetLinksByUri(...)`, and `GetLinksByDestinationName(...)`, page-level AcroForm widget objects with current `/AS` and named `/AP /N` normal appearance states, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags`, named signature flag helpers, and `/DA` metadata, simple AcroForm fields with typed `PdfFormFieldKind`, inherited common `/Ff` flag helpers, scalar and array current/default values, selected/default-selected choice-option matching, inherited text `/MaxLen`, inherited AcroForm/field-tree `/DA` default appearance strings, inherited `/Q` text alignment, inherited simple choice `/Opt` options, distinct field page-number helpers, field-local widget page lookups, named, kind-based, and page-number form-field lookup helpers, document-level `FormWidgets`, `FormWidgetsByFieldName`, `FormWidgetsByPageNumber`, `GetFormWidgets(string)`, `GetFormWidgets(int)`, and simple form-widget page/rectangle objects. Range-based logical loads filter page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages while preserving duplicate selected page widgets in caller order. The two-page line-item statement fixture now guards source-page ordering, table readback, totals readback, and selected range ordering through the logical model. This is the first AST-style surface for PSWriteOffice-style workflows, but heading/paragraph/table/list detection remains heuristic rather than a full tagged-PDF or Word-like semantic reconstruction | +| Read | Logical object model | Partial | `PdfLogicalDocument.Load(byte[]/path/stream, PdfTextLayoutOptions?)`, `LoadPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `From(PdfReadDocument, ...)`, and `FromPageRanges(PdfReadDocument, options, PdfPageRange...)` expose one wrapper-friendly read surface with metadata, selected source pages in caller order when ranges are used, `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)` helpers that preserve range-selection duplicates, document-level `TextBlocks`, `Headings`, `Paragraphs`, `ListItems`, `Tables`, and `Images`, flattened logical `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on both documents and pages, line-level text blocks, heuristic headings, list item objects with marker/level/text hints, heuristic paragraph groups, leader rows, detected tables with row/column/cell objects, image XObjects, URI/named-destination link annotation objects with document-level `Links`, `LinksByUri`, `LinksByDestinationName`, `GetLinksByUri(...)`, and `GetLinksByDestinationName(...)`, page-level AcroForm widget objects with current `/AS` and named `/AP /N` normal appearance states, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags`, named signature flag helpers, and `/DA` metadata, simple AcroForm fields with typed `PdfFormFieldKind`, inherited common `/Ff` flag helpers, scalar and array current/default values, selected/default-selected choice-option matching, inherited text `/MaxLen`, inherited AcroForm/field-tree `/DA` default appearance strings, inherited `/Q` text alignment, inherited simple choice `/Opt` options, distinct field page-number helpers, field-local widget page lookups, named, kind-based, and page-number form-field lookup helpers, document-level `FormWidgets`, `FormWidgetsByFieldName`, `FormWidgetsByPageNumber`, `GetFormWidgets(string)`, `GetFormWidgets(int)`, and simple form-widget page/rectangle objects. `PdfLogicalDocument.ToMarkdown(...)` and `PdfLogicalPage.ToMarkdown(...)` render the same logical model as Markdown with headings, paragraphs, lists, detected tables, image placeholders, and optional link/form annotations. Range-based logical loads filter page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages while preserving duplicate selected page widgets in caller order. The two-page line-item statement fixture now guards source-page ordering, table readback, totals readback, and selected range ordering through the logical model. This is the first AST-style surface for PSWriteOffice-style workflows, but heading/paragraph/table/list detection remains heuristic rather than a full tagged-PDF or Word-like semantic reconstruction | | Read | Simple structure extraction | Partial | `PdfReadPage.ExtractStructured(...)`, `PdfReadDocument.ExtractStructuredPages(...)`, `PdfReadDocument.ExtractHeadingsByPage(...)`, `PdfReadDocument.ExtractListItemsByPage(...)`, `PdfReadDocument.ExtractParagraphsByPage(...)`, `PdfTextExtractor.ExtractStructuredByPage(byte[]/path/stream, options)`, `ExtractStructuredByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractHeadingsByPage(byte[]/path/stream, options)`, `ExtractHeadingsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractListItemsByPage(byte[]/path/stream, options)`, `ExtractListItemsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractParagraphsByPage(byte[]/path/stream, options)`, `ExtractParagraphsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `PdfTextExtractor.ExtractTablesByPage(byte[]/path/stream, options)`, and `ExtractTablesByPageRanges(byte[]/path/stream, options, PdfPageRange...)` expose column-aware text, heuristic headings, heuristic paragraph groups, list item marker/level hints, dot/hyphen/underscore leader rows that preserve decimal/currency value punctuation, and heuristic table rows/geometry for wrapper-friendly readback while preserving selected source page numbers for heading/list-item/paragraph/table results; `PdfTextExtractor.ExtractTablesByPage(pdfBytes, outputDirectory, baseName, options)`, `ExtractTablesByPage(inputPath, outputDirectory, options)`, `ExtractTablesByPage(stream, outputDirectory, baseName, options)`, and matching `ExtractTablesByPageRanges(...)` overloads write deterministic escaped CSV files per detected table for all pages or selected source-page ranges, including the two-page line-item statement fixture with selected source-page order, line-item rows, and totals guarded for wrapper use | | Manipulate | Split by page range | Partial | `PdfPageExtractor.ExtractPageRange(byte[]/path/stream, firstPage, lastPage)`, `ExtractPageRange(..., PdfPageRange)`, `ExtractPageRanges(..., PdfPageRange...)`, `SplitPages(byte[]/path/stream)`, and `SplitPageRanges(..., PdfPageRange...)` return bytes for wrapper pipelines; `PdfPageRange.Parse(...)`, `TryParse(...)`, `ParseMany("1-3,5")`, and `TryParseMany(...)` parse one-based single pages plus inclusive `first-last` / `first..last` range lists while preserving caller order; path and stream split helpers can also write deterministic `source-page-0001.pdf` and `source-pages-0001-0003.pdf` files; simple direct catalog `/PageMode`, `/PageLayout`, `/Version`, `/Lang`, simple direct `/PageLabels` number trees, simple outlines including simple GoTo action outline entries whose destinations point only at copied pages, direct `/Dests` dictionaries, simple `/Names` `/Dests` name trees including leaf `/Kids`, destination-array `/OpenAction` entries, simple GoTo open-action dictionaries, simple `/ViewerPreferences` dictionaries, simple catalog `/Metadata` XMP XML streams, simple catalog `/URI` base dictionaries, simple `/OutputIntents` metadata graphs, simple `/Names` `/EmbeddedFiles` attachment trees, simple catalog `/AF` associated-file arrays, and simple `/OCProperties` optional-content metadata are preserved, with copied-page labels reindexed, stale destinations/open actions pruned, stale outline trees/name-tree destinations dropped, and stale named-destination link annotations removed when their target pages are not copied; the two-page line-item statement fixture now guards split/extract readback through the logical model; currently scoped to PDFs handled by the OfficeIMO parser | | Manipulate | Merge PDFs | Partial | `PdfMerger.Merge(byte[]/stream inputs)` and `PdfMerger.MergeFilesToBytes(path inputs)` can return bytes or write to output streams, while `PdfMerger.MergeFiles(...)` writes merged files from `params` paths or enumerable path lists and can write enumerable file-list inputs to output streams for wrapper pipelines; simple direct catalog `/PageMode`, `/PageLayout`, `/Version`, `/Lang`, simple direct `/PageLabels` number trees, simple outline trees including simple GoTo action outline entries, direct `/Dests` dictionaries, simple `/Names` `/Dests` name trees, destination-array `/OpenAction` entries, simple GoTo open-action dictionaries, simple `/ViewerPreferences` dictionaries, simple catalog `/Metadata` XMP XML streams, simple catalog `/URI` base dictionaries, simple `/OutputIntents` metadata graphs, simple `/Names` `/EmbeddedFiles` attachment trees, simple catalog `/AF` associated-file arrays, and simple `/OCProperties` optional-content metadata are preserved from the first source; the two-page line-item statement fixture now guards merge-after-split readback through the logical model; currently scoped to parser-supported PDFs | diff --git a/OfficeIMO.Pdf/README.md b/OfficeIMO.Pdf/README.md index 5c28d42bf..d5840b613 100644 --- a/OfficeIMO.Pdf/README.md +++ b/OfficeIMO.Pdf/README.md @@ -89,7 +89,7 @@ Reading: - Inspect page count, selected source page ranges, page sizes, orientation, inherited page rotation, catalog page mode/layout/version/language values, simple page-label rules, simple document open-action targets, simple viewer preference entries, simple AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, field names/types/kinds/common flags/text max lengths/default appearance strings/text alignment/choice options/scalar or array values/selected options/field page numbers/field-local widget page lookups/widget field names, geometry, and named annotation flags, form field name/kind/page-number lookup helpers, document-level and page-level form widget list/name/page-number lookup helpers, simple page URI and named-destination link annotation summaries, distinct document-level link URI and internal destination targets, document-level page-aware link lists, named destination names/targets, and per-page link annotations with contents metadata through `PdfInspector`; `InspectPageRanges(...)` preserves caller range order and overlaps while narrowing page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages. - Extract document text, selected page-range text, and page-by-page text from bytes, paths, or streams; helpers can write one UTF-8 text result, or per-page text files, for wrapper pipelines. - Extract text spans with positions. -- Build an initial logical read model through `PdfLogicalDocument.Load(...)` / `From(...)`, exposing logical pages, source-page lookup helpers through `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)`, document-level typed collections for text blocks, headings, paragraphs, list items, tables, and images, generic `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on documents and pages, URI/named-destination link annotation objects with document-level URI/destination lookup, page-level AcroForm widget objects, metadata, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, and simple AcroForm fields with typed field-kind/common-flag helpers, text max length, inherited AcroForm/field-tree default appearance strings, text alignment, choice options, scalar or array values, selected options, distinct field page numbers, field-local widget page lookups, named/kind/page lookup, and document/page-level widget lookup helpers by field name or page number so wrappers can start from one stable object surface instead of stitching together low-level extraction helpers; `LoadPageRanges(...)` and `FromPageRanges(...)` return logical objects for selected source page ranges while preserving caller order and overlaps, and range-based logical loads now expose only page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets represented on selected source pages. +- Build an initial logical read model through `PdfLogicalDocument.Load(...)` / `From(...)`, exposing logical pages, source-page lookup helpers through `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)`, document-level typed collections for text blocks, headings, paragraphs, list items, tables, and images, generic `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on documents and pages, URI/named-destination link annotation objects with document-level URI/destination lookup, page-level AcroForm widget objects, metadata, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, and simple AcroForm fields with typed field-kind/common-flag helpers, text max length, inherited AcroForm/field-tree default appearance strings, text alignment, choice options, scalar or array values, selected options, distinct field page numbers, field-local widget page lookups, named/kind/page lookup, and document/page-level widget lookup helpers by field name or page number so wrappers can start from one stable object surface instead of stitching together low-level extraction helpers; `LoadPageRanges(...)` and `FromPageRanges(...)` return logical objects for selected source page ranges while preserving caller order and overlaps, and range-based logical loads now expose only page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets represented on selected source pages. `PdfLogicalDocument.ToMarkdown(...)` and `PdfLogicalPage.ToMarkdown(...)` render that same logical model as wrapper-friendly Markdown for headings, paragraphs, lists, detected tables, images, and optional link/form annotations without adding a second structure model. - Extract page image XObjects from bytes, paths, streams, or parsed documents with `PdfImageExtractor`; `ExtractImagesByPageRanges(..., PdfPageRange...)` selects reusable page-range lists for wrapper pipelines, JPEG images are returned as JPEG files and simple PNG-predictor Flate images as PNG files, compatible grayscale/RGB Flate images with grayscale `/SMask` alpha are returned as gray-alpha/RGBA PNGs, and helpers can write extracted images to deterministic page-numbered files. - Heuristic column-aware text extraction and simple structured extraction; `PdfTextExtractor` exposes layout-option overloads for bytes, paths, streams, page-range text/structured/heading/list-item/paragraph/table extraction with `PdfPageRange` lists, byte/path/stream whole-document text output to UTF-8 paths or caller-owned streams, and page-file output, plus structured-by-page, heading-by-page, list-item-by-page, paragraph-by-page, and table-by-page extraction that preserves detected lines, heuristic headings, heuristic paragraph groups, list item marker/level hints, dot/hyphen/underscore leader rows with decimal/currency value punctuation, simple table geometry, and selected source page numbers so wrappers can request readback without dropping to `PdfReadDocument`. Byte-, path-, and stream-based text/table extraction can also write deterministic `source-page-0001.txt` and `source-page-0001-table-0001.csv` files for all pages or selected page ranges, including option-aware selected text page output and the two-page line-item statement fixture's selected table output, with CSV escaping for table output. - Decode common simple streams used by many PDFs, including uncompressed, Flate, ASCIIHex, ASCII85, RunLength, and LZW paths. diff --git a/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs b/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs new file mode 100644 index 000000000..d8c3a4453 --- /dev/null +++ b/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs @@ -0,0 +1,288 @@ +namespace OfficeIMO.Pdf; + +/// +/// Options for rendering a logical PDF read model as Markdown. +/// +public sealed class PdfLogicalMarkdownOptions { + /// Emit horizontal-rule separators between logical pages. + public bool IncludePageSeparators { get; set; } = true; + + /// Emit readable placeholders for image elements discovered in the logical model. + public bool IncludeImagePlaceholders { get; set; } = true; + + /// Emit a link annotation section for supported URI and named-destination links. + public bool IncludeLinkAnnotations { get; set; } + + /// Emit a form widget section for AcroForm widgets placed on pages. + public bool IncludeFormWidgets { get; set; } + + /// Markdown text used between pages when is true. + public string PageSeparator { get; set; } = "---"; +} + +/// +/// Markdown rendering helpers for the first-party logical PDF read model. +/// +public static class PdfLogicalMarkdownExtensions { + /// + /// Renders the logical PDF document as Markdown using headings, paragraphs, lists, tables, and optional annotations from the existing logical model. + /// + public static string ToMarkdown(this PdfLogicalDocument document, PdfLogicalMarkdownOptions? options = null) { + Guard.NotNull(document, nameof(document)); + options ??= new PdfLogicalMarkdownOptions(); + + var builder = new StringBuilder(); + for (int i = 0; i < document.Pages.Count; i++) { + if (i > 0 && options.IncludePageSeparators) { + AppendBlock(builder, options.PageSeparator); + } + + AppendPage(builder, document.Pages[i], options); + } + + return builder.ToString().TrimEnd(); + } + + /// + /// Renders a logical PDF page as Markdown using headings, paragraphs, lists, tables, and optional annotations from the existing logical model. + /// + public static string ToMarkdown(this PdfLogicalPage page, PdfLogicalMarkdownOptions? options = null) { + Guard.NotNull(page, nameof(page)); + options ??= new PdfLogicalMarkdownOptions(); + + var builder = new StringBuilder(); + AppendPage(builder, page, options); + return builder.ToString().TrimEnd(); + } + + private static void AppendPage(StringBuilder builder, PdfLogicalPage page, PdfLogicalMarkdownOptions options) { + List items = BuildPageItems(page, options); + items.Sort(CompareMarkdownItems); + + for (int i = 0; i < items.Count; i++) { + AppendBlock(builder, items[i].Markdown); + } + } + + private static List BuildPageItems(PdfLogicalPage page, PdfLogicalMarkdownOptions options) { + var items = new List(); + int sequence = 0; + + for (int i = 0; i < page.Headings.Count; i++) { + PdfLogicalHeading heading = page.Headings[i]; + int level = Math.Min(Math.Max(heading.Level, 1), 6); + items.Add(new MarkdownItem(heading.Line.BaselineY, heading.Line.XStart, sequence++, new string('#', level) + " " + EscapeInline(heading.Text))); + } + + for (int i = 0; i < page.Paragraphs.Count; i++) { + PdfLogicalParagraph paragraph = page.Paragraphs[i]; + items.Add(new MarkdownItem(paragraph.YTop, paragraph.XStart, sequence++, EscapeInline(paragraph.Text))); + } + + for (int i = 0; i < page.ListItems.Count; i++) { + PdfLogicalListItem listItem = page.ListItems[i]; + string indent = new string(' ', Math.Max(listItem.Level - 1, 0) * 2); + items.Add(new MarkdownItem(listItem.Line.BaselineY, listItem.Line.XStart, sequence++, indent + FormatListMarker(listItem.Marker) + " " + EscapeInline(listItem.Text))); + } + + for (int i = 0; i < page.Tables.Count; i++) { + PdfLogicalTable table = page.Tables[i]; + double x = table.Columns.Count > 0 ? table.Columns[0].From : 0; + string markdown = RenderTable(table); + if (markdown.Length > 0) { + items.Add(new MarkdownItem(table.YTop, x, sequence++, markdown)); + } + } + + IReadOnlyList leaderRows = page.GetElements(PdfLogicalElementKind.LeaderRow); + for (int i = 0; i < leaderRows.Count; i++) { + if (leaderRows[i] is PdfLogicalLeaderRow leaderRow) { + items.Add(new MarkdownItem(null, 0, sequence++, EscapeInline(leaderRow.Label) + " | " + EscapeInline(leaderRow.Value))); + } + } + + if (options.IncludeImagePlaceholders) { + for (int i = 0; i < page.Images.Count; i++) { + PdfLogicalImage image = page.Images[i]; + string description = "[Image: page " + image.PageNumber + ", resource " + EscapeInline(image.ResourceName) + ", " + image.Width + "x" + image.Height; + string? mimeType = image.MimeType; + if (!string.IsNullOrEmpty(mimeType)) { + description += ", " + EscapeInline(mimeType!); + } + + description += "]"; + items.Add(new MarkdownItem(null, 0, sequence++, description)); + } + } + + if (options.IncludeLinkAnnotations) { + for (int i = 0; i < page.Links.Count; i++) { + PdfLogicalLinkAnnotation link = page.Links[i]; + string target = link.Uri ?? link.DestinationName ?? string.Empty; + if (target.Length == 0) { + continue; + } + + string label = !string.IsNullOrWhiteSpace(link.Contents) ? link.Contents! : target; + string markdown = link.Uri is not null + ? "[Link: " + EscapeInline(label) + "](" + EscapeLinkTarget(link.Uri) + ")" + : "[Link: " + EscapeInline(label) + " -> " + EscapeInline(target) + "]"; + items.Add(new MarkdownItem(link.Y2, link.X1, sequence++, markdown)); + } + } + + if (options.IncludeFormWidgets) { + for (int i = 0; i < page.FormWidgets.Count; i++) { + PdfLogicalFormWidget widget = page.FormWidgets[i]; + string name = widget.FieldName ?? widget.FieldType ?? "Field"; + string value = widget.Value ?? string.Empty; + items.Add(new MarkdownItem(widget.Y2, widget.X1, sequence++, "[Form field: " + EscapeInline(name) + (value.Length > 0 ? " = " + EscapeInline(value) : string.Empty) + "]")); + } + } + + return items; + } + + private static int CompareMarkdownItems(MarkdownItem left, MarkdownItem right) { + bool leftHasY = left.Y.HasValue; + bool rightHasY = right.Y.HasValue; + if (leftHasY && rightHasY) { + int yComparison = right.Y!.Value.CompareTo(left.Y!.Value); + if (yComparison != 0) { + return yComparison; + } + + int xComparison = left.X.CompareTo(right.X); + if (xComparison != 0) { + return xComparison; + } + } else if (leftHasY != rightHasY) { + return leftHasY ? -1 : 1; + } + + return left.Sequence.CompareTo(right.Sequence); + } + + private static void AppendBlock(StringBuilder builder, string markdown) { + if (string.IsNullOrWhiteSpace(markdown)) { + return; + } + + if (builder.Length > 0) { + builder.AppendLine(); + builder.AppendLine(); + } + + builder.Append(markdown.Trim()); + } + + private static string RenderTable(PdfLogicalTable table) { + if (table.Rows.Count == 0) { + return string.Empty; + } + + int columnCount = 0; + for (int i = 0; i < table.Rows.Count; i++) { + columnCount = Math.Max(columnCount, table.Rows[i].Count); + } + + if (columnCount == 0) { + return string.Empty; + } + + var builder = new StringBuilder(); + AppendTableRow(builder, table.Rows[0], columnCount); + builder.AppendLine(); + AppendTableSeparator(builder, columnCount); + + for (int i = 1; i < table.Rows.Count; i++) { + builder.AppendLine(); + AppendTableRow(builder, table.Rows[i], columnCount); + } + + return builder.ToString(); + } + + private static void AppendTableRow(StringBuilder builder, IReadOnlyList row, int columnCount) { + builder.Append('|'); + for (int i = 0; i < columnCount; i++) { + string cell = i < row.Count ? row[i] : string.Empty; + builder.Append(' '); + builder.Append(EscapeTableCell(cell)); + builder.Append(" |"); + } + } + + private static void AppendTableSeparator(StringBuilder builder, int columnCount) { + builder.Append('|'); + for (int i = 0; i < columnCount; i++) { + builder.Append(" --- |"); + } + } + + private static string FormatListMarker(string marker) { + string trimmed = marker.Trim(); + if (trimmed.Length == 0) { + return "-"; + } + + if (IsNumericMarker(trimmed)) { + char last = trimmed[trimmed.Length - 1]; + return last == '.' || last == ')' + ? trimmed + : trimmed + "."; + } + + return "-"; + } + + private static bool IsNumericMarker(string marker) { + int digitCount = 0; + for (int i = 0; i < marker.Length; i++) { + char ch = marker[i]; + if (char.IsDigit(ch)) { + digitCount++; + continue; + } + + if (ch != '.' && ch != ')') { + return false; + } + } + + return digitCount > 0; + } + + private static string EscapeInline(string text) { + if (string.IsNullOrEmpty(text)) { + return string.Empty; + } + + return text.Replace("\r", " ").Replace("\n", " ").Trim(); + } + + private static string EscapeTableCell(string text) { + return EscapeInline(text).Replace("|", "\\|"); + } + + private static string EscapeLinkTarget(string uri) { + return uri.Replace(")", "%29"); + } + + private sealed class MarkdownItem { + public MarkdownItem(double? y, double x, int sequence, string markdown) { + Y = y; + X = x; + Sequence = sequence; + Markdown = markdown; + } + + public double? Y { get; } + + public double X { get; } + + public int Sequence { get; } + + public string Markdown { get; } + } +} diff --git a/OfficeIMO.Reader/DocumentReader.cs b/OfficeIMO.Reader/DocumentReader.cs index ddd848346..a40e311d3 100644 --- a/OfficeIMO.Reader/DocumentReader.cs +++ b/OfficeIMO.Reader/DocumentReader.cs @@ -81,7 +81,7 @@ public static class DocumentReader { new ReaderHandlerCapability { Id = "officeimo.reader.pdf", DisplayName = "PDF Reader", - Description = "Built-in PDF page extractor.", + Description = "Built-in PDF logical page and markdown extractor.", Kind = ReaderInputKind.Pdf, Extensions = new[] { ".pdf" }, IsBuiltIn = true, @@ -1248,21 +1248,23 @@ private static IEnumerable ReadPowerPoint(Stream stream, string? so private static IEnumerable ReadPdf(string path, ReaderOptions opt, CancellationToken ct) { var fileName = Path.GetFileName(path); - var doc = PdfReadDocument.Load(path); + var doc = PdfLogicalDocument.Load(path); int outIndex = 0; for (int pageIndex = 0; pageIndex < doc.Pages.Count; pageIndex++) { ct.ThrowIfCancellationRequested(); - var pageNumber = pageIndex + 1; - var pageText = doc.Pages[pageIndex].ExtractText(); + var page = doc.Pages[pageIndex]; + var pageNumber = page.PageNumber; + var pageText = BuildPdfPageText(page); if (string.IsNullOrWhiteSpace(pageText)) { yield return BuildPdfEmptyChunk(path, fileName, pageNumber, outIndex); outIndex++; continue; } - var pageChunks = ChunkPdfText(path, fileName, pageNumber, pageText, opt, outIndex, ct, out var nextIndex); + string pageMarkdown = page.ToMarkdown(); + var pageChunks = ChunkPdfText(path, fileName, pageNumber, pageText, pageMarkdown, opt, outIndex, ct, out var nextIndex); outIndex = nextIndex; foreach (var chunk in pageChunks) { yield return chunk; @@ -1273,21 +1275,23 @@ private static IEnumerable ReadPdf(string path, ReaderOptions opt, private static IEnumerable ReadPdf(Stream stream, string? sourceName, ReaderOptions opt, CancellationToken ct) { using var ms = CopyToMemory(stream, ct); var fileName = string.IsNullOrWhiteSpace(sourceName) ? "memory.pdf" : Path.GetFileName(sourceName!.Trim()); - var doc = PdfReadDocument.Load(ms.ToArray()); + var doc = PdfLogicalDocument.Load(ms.ToArray()); int outIndex = 0; for (int pageIndex = 0; pageIndex < doc.Pages.Count; pageIndex++) { ct.ThrowIfCancellationRequested(); - var pageNumber = pageIndex + 1; - var pageText = doc.Pages[pageIndex].ExtractText(); + var page = doc.Pages[pageIndex]; + var pageNumber = page.PageNumber; + var pageText = BuildPdfPageText(page); if (string.IsNullOrWhiteSpace(pageText)) { yield return BuildPdfEmptyChunk(sourceName ?? fileName, fileName, pageNumber, outIndex); outIndex++; continue; } - var pageChunks = ChunkPdfText(sourceName ?? fileName, fileName, pageNumber, pageText, opt, outIndex, ct, out var nextIndex); + string pageMarkdown = page.ToMarkdown(); + var pageChunks = ChunkPdfText(sourceName ?? fileName, fileName, pageNumber, pageText, pageMarkdown, opt, outIndex, ct, out var nextIndex); outIndex = nextIndex; foreach (var chunk in pageChunks) { yield return chunk; @@ -1584,6 +1588,7 @@ private static List ChunkPdfText( string fileName, int pageNumber, string text, + string? markdown, ReaderOptions opt, int startChunkIndex, CancellationToken ct, @@ -1627,10 +1632,32 @@ private static List ChunkPdfText( list.Add(BuildPdfChunk(path, fileName, pageNumber, outIndex, firstLine, current.ToString().TrimEnd(), warnings)); outIndex++; } + + if (!string.IsNullOrWhiteSpace(markdown) && list.Count == 1 && markdown!.Length <= opt.MaxChars) { + list[0].Markdown = markdown.Trim(); + } + nextChunkIndex = outIndex; return list; } + private static string BuildPdfPageText(PdfLogicalPage page) { + if (page.TextBlocks.Count == 0) { + return string.Empty; + } + + var builder = new StringBuilder(); + for (int i = 0; i < page.TextBlocks.Count; i++) { + if (i > 0) { + builder.AppendLine(); + } + + builder.Append(page.TextBlocks[i].Text); + } + + return builder.ToString(); + } + private static ReaderChunk BuildMarkdownChunk( string path, string fileName, diff --git a/OfficeIMO.Reader/README.md b/OfficeIMO.Reader/README.md index 752c6208a..377e80030 100644 --- a/OfficeIMO.Reader/README.md +++ b/OfficeIMO.Reader/README.md @@ -5,7 +5,7 @@ - Excel (`.xlsx`, `.xlsm`) -> table chunks + optional Markdown table previews - PowerPoint (`.pptx`, `.pptm`) -> slide-aligned Markdown chunks (optionally including notes) - Markdown (`.md`, `.markdown`) -> parser-aware heading chunks with preserved fenced/table blocks -- PDF (`.pdf`) -> page-aware text chunks +- PDF (`.pdf`) -> page-aware text chunks with logical Markdown when a page fits in one chunk The goal is to make it easy for tools like chat bots to ingest content deterministically. diff --git a/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs b/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs index 5d025eeed..8035050ac 100644 --- a/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs @@ -107,6 +107,60 @@ cell.Column is not null && Assert.Contains(logical.Elements, element => element.Kind == PdfLogicalElementKind.Image); } + [Fact] + public void ToMarkdown_RendersLogicalHeadingsParagraphsListsTablesAndImages() { + byte[] pdf = PdfDoc.Create(new PdfOptions { + PageWidth = 420, + PageHeight = 360, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36, + DefaultFontSize = 10 + }) + .H1("Logical Heading") + .Paragraph(p => p.Text("Logical readback marker.")) + .Bullets(new[] { "Detected logical bullet" }) + .Table(new[] { + new[] { "Code", "Name", "Qty" }, + new[] { "A-100", "Alpha", "2" }, + new[] { "B-200", "Beta", "14" } + }, style: new PdfTableStyle { + ColumnWidthPoints = new List { 70, 170, 60 }, + HeaderRowCount = 1, + CellPaddingX = 6, + CellPaddingY = 4 + }) + .Image(CreateMinimalRgbPng(), 18, 18) + .ToBytes(); + + PdfLogicalDocument logical = PdfLogicalDocument.Load(pdf, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + + string markdown = logical.ToMarkdown(); + string normalizedMarkdown = Normalize(markdown); + + Assert.Contains("# Logical Heading", markdown, StringComparison.Ordinal); + Assert.Contains("Logicalreadbackmarker.", normalizedMarkdown, StringComparison.Ordinal); + Assert.Contains("-Detectedlogicalbullet", normalizedMarkdown, StringComparison.Ordinal); + Assert.Contains("| Code | Name | Qty |", markdown, StringComparison.Ordinal); + Assert.Contains("| --- | --- | --- |", markdown, StringComparison.Ordinal); + Assert.Contains("| A-100 | Alpha | 2 |", markdown, StringComparison.Ordinal); + Assert.Contains("[Image: page 1", markdown, StringComparison.Ordinal); + AssertContainsInOrder(normalizedMarkdown, + "#LogicalHeading", + "Logicalreadbackmarker.", + "-Detectedlogicalbullet", + "|Code|Name|Qty|", + "[Image:page1"); + + string withoutImages = logical.ToMarkdown(new PdfLogicalMarkdownOptions { + IncludeImagePlaceholders = false + }); + Assert.DoesNotContain("[Image:", withoutImages, StringComparison.Ordinal); + } + [Fact] public void LoadPageRanges_BuildsLogicalModelForSelectedSourcePagesInCallerOrder() { byte[] pdf = BuildThreePageLogicalPdf(); @@ -648,6 +702,16 @@ private static bool RowContains(IReadOnlyList row, params string[] expec return expectedTokens.All(token => rowText.Contains(token, StringComparison.Ordinal)); } + private static void AssertContainsInOrder(string text, params string[] expectedTokens) { + int lastIndex = -1; + for (int i = 0; i < expectedTokens.Length; i++) { + int index = text.IndexOf(expectedTokens[i], StringComparison.Ordinal); + Assert.True(index >= 0, $"Expected token '{expectedTokens[i]}' was not found."); + Assert.True(index > lastIndex, $"Expected token '{expectedTokens[i]}' to appear after the previous token."); + lastIndex = index; + } + } + private static byte[] BuildThreePageLogicalPdf() { return PdfDoc.Create(new PdfOptions { PageWidth = 260, diff --git a/OfficeIMO.Tests/Reader.DocumentReader.cs b/OfficeIMO.Tests/Reader.DocumentReader.cs index 43a9fc9b1..6447f4e7c 100644 --- a/OfficeIMO.Tests/Reader.DocumentReader.cs +++ b/OfficeIMO.Tests/Reader.DocumentReader.cs @@ -563,7 +563,15 @@ public void DocumentReader_MarkdownChunking_AssignsDeterministicSlugsForNonAscii public void DocumentReader_CanReadPdf() { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".pdf"); try { - var pdf = PdfDoc.Create(); + var pdf = PdfDoc.Create(new PdfOptions { + PageWidth = 420, + PageHeight = 360, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36, + DefaultFontSize = 10 + }); pdf.H1("PDF Title"); pdf.Paragraph(p => p.Text("This is a PDF body.")); pdf.Save(path); @@ -572,6 +580,10 @@ public void DocumentReader_CanReadPdf() { Assert.NotEmpty(chunks); Assert.Contains(chunks, c => c.Kind == ReaderInputKind.Pdf); Assert.Contains(chunks, c => c.Location.Page.HasValue && c.Location.Page.Value >= 1); + var pdfChunk = Assert.Single(chunks, c => c.Kind == ReaderInputKind.Pdf); + Assert.NotNull(pdfChunk.Markdown); + Assert.Contains("# PDF Title", pdfChunk.Markdown, StringComparison.Ordinal); + Assert.Contains("ThisisaPDFbody.", NormalizeWhitespace(pdfChunk.Markdown!), StringComparison.Ordinal); } finally { if (File.Exists(path)) File.Delete(path); } @@ -1413,4 +1425,8 @@ public void DocumentReader_ReadFolderDetailed_IncludeChunksTrue_KeepsSummaryChun if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } } + + private static string NormalizeWhitespace(string text) { + return new string(text.Where(ch => !char.IsWhiteSpace(ch)).ToArray()); + } } From 3f1788a62f5cb4b2be8b4b254549c023a88d021f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 31 May 2026 22:36:07 +0200 Subject: [PATCH 03/18] Advance first-party PDF roadmap --- Docs/officeimo.excel.roadmap.md | 2 +- Docs/officeimo.pdf.roadmap.md | 89 +- Docs/officeimo.pdf.support-matrix.md | 32 +- .../Converters/Pdf/Pdf.CustomFonts.cs | 19 +- ...df.License.cs => Pdf.FirstPartyOptions.cs} | 14 +- .../Converters/Pdf/Pdf.SaveAsPdf.cs | 15 +- .../Converters/Pdf/Pdf01_SaveAsPdf.cs | 14 +- OfficeIMO.Examples/Program.cs | 2 +- .../ExcelPdfConverterExtensions.cs | 4040 ++++++++++++ OfficeIMO.Excel.Pdf/ExcelPdfExportWarning.cs | 33 + OfficeIMO.Excel.Pdf/ExcelPdfSaveOptions.cs | 150 + .../OfficeIMO.Excel.Pdf.csproj | 51 + OfficeIMO.Excel.Pdf/README.md | 27 + OfficeIMO.Excel/ExcelCell.cs | 10 +- OfficeIMO.Excel/ExcelChart.cs | 113 + OfficeIMO.Excel/ExcelChartSnapshot.cs | 51 + OfficeIMO.Excel/ExcelChartUtils.cs | 2 +- OfficeIMO.Excel/ExcelDocument.Inspection.cs | 8 +- OfficeIMO.Excel/ExcelImage.cs | 93 + OfficeIMO.Excel/ExcelInspectionSnapshot.cs | 49 + OfficeIMO.Excel/ExcelRuleInfo.cs | 10 + OfficeIMO.Excel/ExcelSheet.CellStyle.cs | 175 + OfficeIMO.Excel/ExcelSheet.CellValue.cs | 35 + .../ExcelSheet.ColumnDefinitions.cs | 34 + OfficeIMO.Excel/ExcelSheet.ColumnsRows.cs | 74 +- OfficeIMO.Excel/ExcelSheet.HeadersFooters.cs | 417 +- OfficeIMO.Excel/ExcelSheet.Hyperlinks.cs | 43 + OfficeIMO.Excel/ExcelSheet.MergedRanges.cs | 44 + OfficeIMO.Excel/ExcelSheet.PrintSettings.cs | 316 + OfficeIMO.Excel/ExcelSheet.RowDefinitions.cs | 38 + OfficeIMO.Excel/ExcelSheet.RuleManagement.cs | 80 +- OfficeIMO.Excel/ExcelSheet.Visibility.cs | 5 + OfficeIMO.Pdf/Compose/PdfElementCompose.cs | 10 +- OfficeIMO.Pdf/Compose/PdfFooterCompose.cs | 39 + OfficeIMO.Pdf/Compose/PdfHeaderCompose.cs | 39 + OfficeIMO.Pdf/Compose/PdfItemCompose.cs | 14 +- OfficeIMO.Pdf/Compose/PdfPageCompose.cs | 5 + OfficeIMO.Pdf/Compose/PdfRowColumnCompose.cs | 45 +- OfficeIMO.Pdf/Compose/PdfRowCompose.cs | 9 + OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs | 54 +- OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs | 130 +- OfficeIMO.Pdf/Model/BulletListBlock.cs | 30 +- OfficeIMO.Pdf/Model/CheckBoxBlock.cs | 4 +- OfficeIMO.Pdf/Model/ChoiceFieldBlock.cs | 8 +- OfficeIMO.Pdf/Model/HeadingBlock.cs | 25 +- OfficeIMO.Pdf/Model/NumberedListBlock.cs | 35 +- OfficeIMO.Pdf/Model/PanelStyle.cs | 33 + OfficeIMO.Pdf/Model/PdfCellBorder.cs | 73 +- OfficeIMO.Pdf/Model/PdfCellBorderLineStyle.cs | 12 + OfficeIMO.Pdf/Model/PdfCellBorderSide.cs | 37 + OfficeIMO.Pdf/Model/PdfCellDataBar.cs | 31 + OfficeIMO.Pdf/Model/PdfCellIcon.cs | 43 + OfficeIMO.Pdf/Model/PdfCellIconKind.cs | 19 + OfficeIMO.Pdf/Model/PdfCellPadding.cs | 61 + OfficeIMO.Pdf/Model/PdfFormFieldStyle.cs | 43 + OfficeIMO.Pdf/Model/PdfHeaderFooterImage.cs | 52 + OfficeIMO.Pdf/Model/PdfHeaderFooterShape.cs | 34 + OfficeIMO.Pdf/Model/PdfHeadingStyle.cs | 8 + OfficeIMO.Pdf/Model/PdfListItem.cs | 65 + OfficeIMO.Pdf/Model/PdfPanelBorder.cs | 29 + OfficeIMO.Pdf/Model/PdfParagraphBuilder.cs | 37 +- OfficeIMO.Pdf/Model/PdfRowStyle.cs | 15 + OfficeIMO.Pdf/Model/PdfTableCell.cs | 187 +- OfficeIMO.Pdf/Model/PdfTableCellCheckBox.cs | 41 + OfficeIMO.Pdf/Model/PdfTableCellFormField.cs | 120 + OfficeIMO.Pdf/Model/PdfTableCellImage.cs | 71 + OfficeIMO.Pdf/Model/PdfTableStyle.cs | 200 +- OfficeIMO.Pdf/Model/RadioButtonGroupBlock.cs | 68 + OfficeIMO.Pdf/Model/TableBlock.cs | 19 + OfficeIMO.Pdf/Model/TextFieldBlock.cs | 4 +- OfficeIMO.Pdf/Model/TextRun.cs | 52 +- OfficeIMO.Pdf/Options/PdfOptions.cs | 233 +- OfficeIMO.Pdf/README.md | 61 +- .../Reading/Model/PdfValidationResult.cs | 76 + OfficeIMO.Pdf/Reading/PdfTextExtractor.cs | 263 + OfficeIMO.Pdf/Reading/PdfValidator.cs | 34 + OfficeIMO.Pdf/Rendering/PdfWriter.cs | 86 +- .../Writer/PdfAcroFormDictionaryBuilder.cs | 85 +- .../Writer/PdfAnnotationDictionaryBuilder.cs | 128 +- .../Writer/PdfWriter.ContentStream.cs | 5 + .../Rendering/Writer/PdfWriter.Drawing.cs | 211 +- .../Rendering/Writer/PdfWriter.Fonts.cs | 30 + .../Rendering/Writer/PdfWriter.Layout.cs | 1872 +++++- .../Rendering/Writer/PdfWriter.Objects.cs | 7 +- .../Rendering/Writer/PdfWriter.Text.cs | 171 +- OfficeIMO.Tests/Excel.ClosedXmlGapFeatures.cs | 6 +- .../Excel.ConditionalFormatting.cs | 43 + OfficeIMO.Tests/OfficeIMO.Tests.csproj | 5 +- .../PackageDependencyGuardrails.cs | 1 + OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 1651 +++++ .../Pdf/PdfComposePageOptionsTests.cs | 109 +- OfficeIMO.Tests/Pdf/PdfDocBulletListTests.cs | 117 + .../Pdf/PdfDocImageValidationTests.cs | 22 + .../Pdf/PdfDocRasterVisualBaselineTests.cs | 504 ++ .../Pdf/PdfDocVisualQualityTests.cs | 1772 +++++- OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs | 226 +- OfficeIMO.Tests/Pdf/PdfInspectorTests.cs | 64 + .../Pdf/PdfLogicalDocumentTests.cs | 62 + OfficeIMO.Tests/Pdf/PdfRowComposeTests.cs | 37 + .../Pdf/PdfTextExtractorPageTests.cs | 210 + .../Pdf/RichParagraphWrappingTests.cs | 17 + ...ive-excel-daily-workbook.page1.poppler.png | Bin 0 -> 31081 bytes ...ive-excel-daily-workbook.page2.poppler.png | Bin 0 -> 9696 bytes ...native-word-daily-layout.page1.poppler.png | Bin 0 -> 59406 bytes ...o-pdf-native-word-report.page1.poppler.png | Bin 0 -> 55151 bytes ...ble-cell-picture-control.page1.poppler.png | Bin 0 -> 15088 bytes .../Pdf/Word.SaveAsPdf.CustomFonts.cs | 136 - .../Pdf/Word.SaveAsPdf.Footnotes.cs | 53 + .../Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs | 115 + OfficeIMO.Tests/Pdf/Word.SaveAsPdf.License.cs | 152 - .../Pdf/Word.SaveAsPdf.Metadata.cs | 42 +- .../Pdf/Word.SaveAsPdf.PageNumbers.cs | 116 + .../Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs | 1079 +++- .../Pdf/Word.SaveAsPdf.Sections.cs | 1074 ++++ OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Shapes.cs | 171 +- OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs | 55 +- .../Pdf/Word.SaveAsPdf.TableStyles.cs | 868 +++ OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs | 132 +- OfficeIMO.Tests/Word.Tables.cs | 3 + OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj | 15 +- OfficeIMO.Word.Pdf/PdfExportWarning.cs | 38 + OfficeIMO.Word.Pdf/PdfSaveOptions.cs | 75 +- OfficeIMO.Word.Pdf/README.md | 2 + OfficeIMO.Word.Pdf/TableLayoutCache.cs | 53 +- .../WordPdfConverterExtensions.Helpers.cs | 19 - .../WordPdfConverterExtensions.Native.cs | 5484 +++++++++++++++++ .../WordPdfConverterExtensions.Rendering.cs | 474 -- .../WordPdfConverterExtensions.Tables.cs | 101 - .../WordPdfConverterExtensions.cs | 550 +- .../Converters/DocumentTraversal.cs | 21 +- OfficeIMO.Word/README.md | 2 +- OfficeIMO.Word/WordShape.cs | 15 +- OfficeIMO.Word/WordTableCell.cs | 68 + OfficeIMO.Word/WordTableOfContent.cs | 60 + OfficeIMO.Word/WordTableRow.cs | 9 +- OfficeIMO.sln | 14 + README.md | 12 +- Website/content/blog/aot-trimming-office.md | 6 +- Website/content/blog/word-to-pdf-linux.md | 2 +- .../getting-started/platform-support/index.md | 4 +- Website/content/docs/index.md | 2 +- Website/content/pages/comparison.md | 2 +- Website/content/pages/third-party.md | 8 +- 143 files changed, 24494 insertions(+), 2532 deletions(-) rename OfficeIMO.Examples/Converters/Pdf/{Pdf.License.cs => Pdf.FirstPartyOptions.cs} (56%) create mode 100644 OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs create mode 100644 OfficeIMO.Excel.Pdf/ExcelPdfExportWarning.cs create mode 100644 OfficeIMO.Excel.Pdf/ExcelPdfSaveOptions.cs create mode 100644 OfficeIMO.Excel.Pdf/OfficeIMO.Excel.Pdf.csproj create mode 100644 OfficeIMO.Excel.Pdf/README.md create mode 100644 OfficeIMO.Excel/ExcelChartSnapshot.cs create mode 100644 OfficeIMO.Excel/ExcelSheet.CellStyle.cs create mode 100644 OfficeIMO.Excel/ExcelSheet.ColumnDefinitions.cs create mode 100644 OfficeIMO.Excel/ExcelSheet.MergedRanges.cs create mode 100644 OfficeIMO.Excel/ExcelSheet.PrintSettings.cs create mode 100644 OfficeIMO.Excel/ExcelSheet.RowDefinitions.cs create mode 100644 OfficeIMO.Pdf/Model/PdfCellBorderLineStyle.cs create mode 100644 OfficeIMO.Pdf/Model/PdfCellBorderSide.cs create mode 100644 OfficeIMO.Pdf/Model/PdfCellDataBar.cs create mode 100644 OfficeIMO.Pdf/Model/PdfCellIcon.cs create mode 100644 OfficeIMO.Pdf/Model/PdfCellIconKind.cs create mode 100644 OfficeIMO.Pdf/Model/PdfCellPadding.cs create mode 100644 OfficeIMO.Pdf/Model/PdfFormFieldStyle.cs create mode 100644 OfficeIMO.Pdf/Model/PdfHeaderFooterImage.cs create mode 100644 OfficeIMO.Pdf/Model/PdfHeaderFooterShape.cs create mode 100644 OfficeIMO.Pdf/Model/PdfListItem.cs create mode 100644 OfficeIMO.Pdf/Model/PdfPanelBorder.cs create mode 100644 OfficeIMO.Pdf/Model/PdfTableCellCheckBox.cs create mode 100644 OfficeIMO.Pdf/Model/PdfTableCellFormField.cs create mode 100644 OfficeIMO.Pdf/Model/PdfTableCellImage.cs create mode 100644 OfficeIMO.Pdf/Model/RadioButtonGroupBlock.cs create mode 100644 OfficeIMO.Pdf/Reading/Model/PdfValidationResult.cs create mode 100644 OfficeIMO.Pdf/Reading/PdfValidator.cs create mode 100644 OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs create mode 100644 OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-excel-daily-workbook.page1.poppler.png create mode 100644 OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-excel-daily-workbook.page2.poppler.png create mode 100644 OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-word-daily-layout.page1.poppler.png create mode 100644 OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-word-report.page1.poppler.png create mode 100644 OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-word-table-cell-picture-control.page1.poppler.png delete mode 100644 OfficeIMO.Tests/Pdf/Word.SaveAsPdf.CustomFonts.cs delete mode 100644 OfficeIMO.Tests/Pdf/Word.SaveAsPdf.License.cs create mode 100644 OfficeIMO.Word.Pdf/PdfExportWarning.cs delete mode 100644 OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Helpers.cs create mode 100644 OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs delete mode 100644 OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Rendering.cs delete mode 100644 OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Tables.cs diff --git a/Docs/officeimo.excel.roadmap.md b/Docs/officeimo.excel.roadmap.md index d54af3eff..8eec5180f 100644 --- a/Docs/officeimo.excel.roadmap.md +++ b/Docs/officeimo.excel.roadmap.md @@ -132,7 +132,7 @@ Improve collaboration metadata without making it heavy. Keep rendering scoped until the implementation path is proven. - Run a feasibility spike for report-grade PDF, HTML, and image output using existing OfficeIMO primitives where possible. -- Start with sheets produced by OfficeIMO report APIs: tables, simple styles, merged cells, images, headers/footers, page setup, and chart placeholders or generated chart images. +- Start with sheets produced by OfficeIMO report APIs: tables, simple styles, merged cells, images, headers/footers, page setup, and chart placeholders or generated chart images. Initial `OfficeIMO.Excel.Pdf` export now covers worksheet table and image export with print areas, orientation, margins, hidden workbook worksheet filtering, hidden rows and columns, repeated print-title rows, simple header/footer text zones with page/date/time/workbook tokens, supported images, and simple line-level font/size/color formatting when representable by one PDF header/footer line style, worksheet merged-cell spans, external cell hyperlinks, exact exported-cell internal workbook-link destinations with sheet-level fallback, supported worksheet drawing images, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, fit-to-width table sizing, common number formats, and basic explicit cell font/fill/conditional-color-scale/data-bar/icon-set/alignment/border styling through the first-party `OfficeIMO.Pdf` engine. - Add export APIs only after the scoped renderer is reliable enough for report workflows. ## Product Principles diff --git a/Docs/officeimo.pdf.roadmap.md b/Docs/officeimo.pdf.roadmap.md index 2724fe3a9..bce31b359 100644 --- a/Docs/officeimo.pdf.roadmap.md +++ b/Docs/officeimo.pdf.roadmap.md @@ -16,7 +16,7 @@ This roadmap tracks the path for `OfficeIMO.Pdf` to become a serious MIT-license - Strong enough to become the export engine for `OfficeIMO.Word`, `OfficeIMO.Excel`, and `OfficeIMO.PowerPoint`. - Friendly enough to expose later through PSWriteOffice PowerShell cmdlets. -QuestPDF can remain a temporary engine for `OfficeIMO.Word.Pdf`, but the strategic target is to replace that dependency with `OfficeIMO.Pdf` once the first-party layout engine is good enough. +`OfficeIMO.Word.Pdf` now routes through `OfficeIMO.Pdf`; the remaining roadmap work is fidelity, coverage, and visual quality rather than preserving the old external engine path. The public API should stay Word-like and document-model oriented. Polished invoices, statements, reports, letters, and similar samples are quality fixtures and wrapper examples, not first-class concepts in the engine. If a fixture needs something, the reusable primitive should normally be a section, paragraph, table, style, drawing, image, header/footer, page setup, or layout-flow feature rather than an `Invoice`-style API. @@ -144,17 +144,17 @@ For reading and manipulation: - Basic PDF generation. - Metadata. -- Standard PDF fonts, with shared option, composition, stamping, writer style-selection, metric, and base-font-name enum validation that rejects invalid values instead of falling back silently; writer font-family normalization preserves Helvetica, Times, and Courier families across regular, oblique, bold, and bold-oblique variants, Helvetica and Times family text measurement plus standard-font text span readback use built-in glyph-width tables, including common WinAnsi punctuation and accented Latin letters, instead of average character widths, and generated/stamped text now reports unsupported WinAnsi characters instead of replacing them with `?`. +- Standard PDF fonts, with shared option, composition, rich-run, stamping, writer style-selection, metric, and base-font-name enum validation that rejects invalid values instead of falling back silently; writer font-family normalization preserves Helvetica, Times, and Courier families across regular, oblique, bold, and bold-oblique variants, including mixed standard-font rich paragraph runs, Helvetica and Times family text measurement plus standard-font text span readback use built-in glyph-width tables, including common WinAnsi punctuation and accented Latin letters, instead of average character widths, and generated/stamped text now reports unsupported WinAnsi characters instead of replacing them with `?`. - Headings and paragraphs. Shared simple text wrapping now preserves explicit hard line breaks for headings, table cells, list items, captions, and other non-rich text surfaces instead of collapsing them into ordinary spaces. -- Rich text runs. Initial rich paragraph wrapping, alignment, justification, underline/strike/link rectangles, superscript/subscript baseline shifts, scaled run measurement, Word-compatible default half-inch tab stops with paragraph-style overrides, explicit paragraph tab runs with dotted, hyphen, and underscore leaders plus left, center, right, and decimal-aligned values, and line breaking use proportional Helvetica/Times-family standard-font glyph widths instead of average character counts. -- Bullets and numbered lists. +- Rich text runs. Initial rich paragraph wrapping, alignment, justification, scoped per-run standard font-family, font-size, and background/highlight changes, underline/strike/link rectangles, superscript/subscript baseline shifts, scaled run measurement, Word-compatible default half-inch tab stops with paragraph-style overrides, explicit paragraph tab runs with dotted, hyphen, and underscore leaders plus left, center, right, and decimal-aligned values, and line breaking use proportional Helvetica/Times-family standard-font glyph widths instead of average character counts. +- Bullets and numbered lists, including simple string items plus rich `PdfListItem` runs through `RichBullets(...)` and `RichNumbered(...)`. - Horizontal rules with reusable `PdfHorizontalRuleStyle` thickness, color, spacing, keep-with-next behavior, and document/page/theme defaults. - Panels. -- Tables. +- Tables. Oversized fixed column-width tables now proportionally fit into the available table frame in top-level and row/column flows so Word-origin fixed-width tables can render inside narrower section columns while impossible minimum-width conflicts still fail clearly. - Links. Paragraph, heading, image, shape, drawing-scene, vector convenience, and table-cell URI annotations are now generic document primitives, including wrapped heading lines, aligned row/column headings, top-level/row-column images, fixed visual flow objects, top-level and compose/row-column vector helper calls, top-level tables, compose/row-column table flows, and linked column/row-spanned table cells whose annotations cover the merged text frame, with escaped `/Contents` metadata sourced from link text, heading text, image/shape/drawing/vector metadata, or cell text and inspector readback for generated heading, image, shape, drawing, and convenience-vector links. - JPEG images and simple non-interlaced 8-bit grayscale/grayscale-alpha/RGB/RGBA PNG images, including PNG alpha soft masks, with reusable `PdfImageStyle` alignment, fit, clip, spacing, keep-with-next behavior, and document/page/theme defaults plus validation backed by `OfficeIMO.Drawing.OfficeImageReader`. - PDF RGB color values validate components before writer/operator use. -- Rows/columns with configurable and reusable style-driven gutters, row-level spacing rhythm, keep-together and keep-with-next page flow, column-local item groups, bullet/numbered lists, panel paragraphs, and compact tables. +- Rows/columns with configurable and reusable style-driven gutters, optional vertical column separators, row-level spacing rhythm, keep-together and keep-with-next page flow, column-local item groups, bullet/numbered lists, panel paragraphs, and compact tables. The native Word exporter now maps Word section columns with explicit column breaks, explicit unequal section column widths, Word section column separators, and a heading/keep-with-next-aware automatic distribution fallback for sections without explicit breaks through this same row/column flow. - Headers and footers/page numbers, with simple header text/page-token rendering, Word-like left/center/right text zones for running, first-page, and even-page variants, zone fit/overlap validation, visible `{page}` / `{pages}` tokens that continue across flows by default, configurable visible page-number starts, decimal/roman/alphabetic page-number styles, and section-local first/even/odd variant selection for Word-like section numbering, header font/alignment/placement validation, footer segment construction validation, assigned/readback footer segment list snapshots, direct segment-template rendering without requiring the page-number flag, and shared footer placement validation for page-number and segment-based footers. - Invisible `Spacer(...)` flow blocks for document, page-content, column, nested element, and row/column composition, plus direct page-content, column, item, and nested element `PageBreak()` flow transitions, so business-shaped fixtures can add generic rhythm and pagination without inserting fake blank text or template-specific engine concepts into extracted content. - Save to file/bytes, with shared sync/async path validation and async path cancellation before creating directories, rendering, or writing files. @@ -169,7 +169,7 @@ For reading and manipulation: - Simple structured extraction for lines, lists, leader rows, and tables. - Text and image extraction directory-output helpers validate/create output directories before reading path inputs, reject file targets with clear argument errors, accept byte-array and stream inputs with caller-provided base names, and write deterministic page-numbered files for wrapper-friendly PSWritePDF parity; image extraction also accepts `PdfPageRange` range lists for byte/path/stream/document inputs and deterministic selected source-page image files, preserving caller order while deduplicating overlaps. Compatible grayscale/RGB Flate image XObjects with grayscale `/SMask` alpha extract as gray-alpha/RGBA PNG files so OfficeIMO-authored PNG alpha can round-trip through extraction. - Header-version, encryption-marker, digital-signature-marker, form-field-marker, annotation-marker, outline/bookmark-marker, catalog-view-setting-marker, page-label-marker, catalog-name-tree-marker, named-destination-marker, open-action-marker, viewer-preference-marker, tagged-structure-marker, XMP-metadata-marker, catalog-URI-marker, output-intent-marker, embedded-file-marker, optional-content-marker, and active-content-marker probing through `PdfInspector.Probe` without full parsing. -- Read/rewrite preflight reports through `PdfInspector.Preflight`, including `CanRead`, `CanRewrite`, parsed document info when available, structured `ReadBlockers` / `RewriteBlockers`, `HasReadBlocker(...)` / `HasRewriteBlocker(...)` helpers, and diagnostics suitable for PSWriteOffice wrappers; encrypted PDFs, missing-header inputs, empty-page-tree PDFs, parser-unsupported PDFs, and PDFs whose page content streams use unsupported filters expose read blockers, form PDFs remain readable but are rewrite-blocked until form preservation/fill/flatten support exists, complex open-action dictionaries, complex non-GoTo outline actions, complex page labels, unsupported catalog name trees, malformed or unsupported named-destination name trees, complex viewer preferences, complex XMP metadata, complex catalog URI dictionaries, complex output intents, complex embedded/associated files, complex optional content, or active-content PDFs remain readable but are rewrite-blocked until catalog/document metadata preservation exists, tagged PDFs remain readable but are rewrite-blocked until accessibility structure preservation exists, and simple direct catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array open actions, simple GoTo open-action dictionaries, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, simple associated-file arrays, plus simple optional-content metadata are detected without blocking rewrite. +- Read/rewrite validation and preflight reports through `PdfValidator.Validate` / `PdfInspector.Preflight`, including `IsValid`, `CanRead`, `CanRewrite`, parsed document info when available, structured `ReadBlockers` / `RewriteBlockers`, `HasReadBlocker(...)` / `HasRewriteBlocker(...)` helpers, and diagnostics suitable for PSWriteOffice wrappers; encrypted PDFs, missing-header inputs, empty-page-tree PDFs, parser-unsupported PDFs, and PDFs whose page content streams use unsupported filters expose read blockers, form PDFs remain readable but are rewrite-blocked until form preservation/fill/flatten support exists, complex open-action dictionaries, complex non-GoTo outline actions, complex page labels, unsupported catalog name trees, malformed or unsupported named-destination name trees, complex viewer preferences, complex XMP metadata, complex catalog URI dictionaries, complex output intents, complex embedded/associated files, complex optional content, or active-content PDFs remain readable but are rewrite-blocked until catalog/document metadata preservation exists, tagged PDFs remain readable but are rewrite-blocked until accessibility structure preservation exists, and simple direct catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array open actions, simple GoTo open-action dictionaries, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, simple associated-file arrays, plus simple optional-content metadata are detected without blocking rewrite. - Page count, page size, orientation, rotation, catalog page mode/layout/version/language values, simple page-label rules, simple document open-action targets, simple viewer preference entries, simple AcroForm field names/types/simple values, simple page URI link annotation summary counts, distinct document-level link URI targets, document-level page-aware link lists, named destination names/targets, and per-page annotations with contents metadata, header version, digital signature presence, form-field presence, annotation presence, outline/bookmark presence, catalog-view-setting presence, page-label presence, catalog-name-tree presence, named-destination presence, open-action presence, viewer-preference presence, tagged-structure presence, XMP metadata presence, catalog URI presence, output-intent presence, embedded-file presence, optional-content presence, and active-content presence inspection through `PdfInspector`. - Initial byte/path/stream page extraction, single-range and multi-range extraction with output stream writes for byte, stream, and path inputs, byte-returning path helpers, repeated selected-page/range cloning, single-page splitting, inclusive `PdfPageRange` chunk splitting through `PdfPageExtractor`, and enumerable file-list merge output to paths or streams through `PdfMerger`, with `PdfPageRange` overloads for wrapper-friendly range selection, preserving simple direct catalog `/PageMode`, `/PageLayout`, `/Version`, `/Lang`, simple direct `/PageLabels` number trees, simple outline trees including simple GoTo action outline entries whose destinations point only at copied pages, direct `/Dests` dictionaries, simple `/Names` `/Dests` name trees, destination-array and simple GoTo dictionary `/OpenAction` entries, simple `/ViewerPreferences` dictionaries, simple catalog `/Metadata` XMP XML streams, simple catalog `/URI` base dictionaries, simple `/OutputIntents` metadata graphs, simple `/Names` `/EmbeddedFiles` attachment trees, simple catalog `/AF` associated-file arrays, and simple `/OCProperties` optional-content metadata while reindexing copied-page labels, pruning stale destinations/open actions, and dropping stale outline trees/name-tree destinations whose target pages are not copied. - Rewrite-style stream serialization now separates `PdfStream` dictionary emission from stream body wrapping so extraction, merge, edit, stamp, and watermark outputs share a cleaner object serialization path, copied-object references are normalized to generation 0 because rewrite outputs currently emit all cloned indirect objects as generation 0, rewrite graph collection/serialization rejects wrong-generation source references instead of silently remapping them to the active object, and `PdfInspector.Preflight` reports invalid rewrite object references before wrapper operations start. @@ -191,7 +191,7 @@ For reading and manipulation: - Reusable default text style now applies Word-like default font, font size, and color to following page-flow content through `PdfTextStyle`, `PdfDoc.DefaultTextStyle(...)`, and `PdfPageCompose.DefaultTextStyle(...)`; no-options documents now start with Helvetica body/header/footer fonts so the plain engine default is proportional and document-like instead of monospace. - Default heading styles now apply reusable Word-like H1/H2/H3 font size, line height, color, spacing before/after, and keep-with-next behavior to top-level and row/column headings when a heading does not provide an explicit style; heading spacing-before is preserved between visible blocks but suppressed at fresh page/column starts to avoid artificial top gaps; headings can keep with following visible paragraph/list/panel/table/rule/image/shape/drawing/row-section neighbors instead of only following paragraphs; callers can set defaults up front with `PdfOptions.DefaultHeadingStyles`, incrementally with `PdfOptions.SetDefaultHeadingStyle(...)`, fluently with `PdfDoc.DefaultHeadingStyle(...)`, page-by-page with `PdfPageCompose.DefaultHeadingStyle(...)`, or directly per heading through `H1/H2/H3(..., style: ...)`; compose item/element and row-column heading helpers now also expose explicit `align` and `color` overloads so local visual control stays generic and Word-like. - Default list styles now apply reusable Word-like bullet and numbered list font size, line height, left indent, marker gap, color, spacing before/after, inter-item rhythm, keep-together, and keep-with-next page flow to top-level and row/column lists when a list does not provide an explicit style; callers can set them up front with `PdfOptions.DefaultListStyle`, fluently with `PdfDoc.DefaultListStyle(...)`, page-by-page with `PdfPageCompose.DefaultListStyle(...)`, or directly per list through `Bullets/Numbered(..., style: ...)`. -- Default panel styles now apply reusable Word-like boxed paragraph background, border, padding, max width, alignment, spacing, keep-together, and keep-with-next behavior to top-level and row/column panel paragraphs when a panel does not provide an explicit style; callers can set them up front with `PdfOptions.DefaultPanelStyle`, fluently with `PdfDoc.DefaultPanelStyle(...)`, page-by-page with `PdfPageCompose.DefaultPanelStyle(...)`, or directly per panel through `PanelParagraph(..., style: ...)`. +- Default panel styles now apply reusable Word-like boxed paragraph background, uniform or side-specific border, padding, max width, alignment, spacing, keep-together, and keep-with-next behavior to top-level and row/column panel paragraphs when a panel does not provide an explicit style; callers can set them up front with `PdfOptions.DefaultPanelStyle`, fluently with `PdfDoc.DefaultPanelStyle(...)`, page-by-page with `PdfPageCompose.DefaultPanelStyle(...)`, or directly per panel through `PanelParagraph(..., style: ...)`. - Default horizontal rule styles now apply reusable Word-like separator thickness, color, spacing before/after, and keep-with-next behavior to top-level and row/column rules when a rule does not provide an explicit style; callers can set them up front with `PdfOptions.DefaultHorizontalRuleStyle`, fluently with `PdfDoc.DefaultHorizontalRuleStyle(...)`, page-by-page with `PdfPageCompose.DefaultHorizontalRuleStyle(...)`, or directly per rule through `HR(..., style: ...)`. - Default image styles now apply reusable Word-like image alignment, fit, clipping, spacing before/after, and keep-with-next behavior to top-level and row/column images when an image does not provide an explicit style; callers can set them up front with `PdfOptions.DefaultImageStyle`, fluently with `PdfDoc.DefaultImageStyle(...)`, page-by-page with `PdfPageCompose.DefaultImageStyle(...)`, or directly per image through `Image(..., style: ...)`. - Default drawing styles now apply reusable Word-like shape and drawing-scene alignment, spacing before/after, and keep-with-next behavior to top-level and row/column vector objects when an object does not provide an explicit style; callers can set them up front with `PdfOptions.DefaultDrawingStyle`, fluently with `PdfDoc.DefaultDrawingStyle(...)`, page-by-page with `PdfPageCompose.DefaultDrawingStyle(...)`, or directly per shape/drawing through `Shape(..., style: ...)` and `Drawing(..., style: ...)`. @@ -206,7 +206,7 @@ For reading and manipulation: - Heading page-flow and style checks now cover Word-like orphan prevention, style snapshotting, theme propagation, page-scoped defaults, rendered font size/color, compose item/element and row-column alignment/color overrides, aligned heading-link rectangles, spacing-before/after rhythm with fresh page/column top suppression, and proportional standard-font wrapping for wide/narrow glyph runs in top-level and row/column flows so headings stay with following paragraphs and no longer rely only on hardcoded renderer constants. - List style checks now cover snapshotting, theme propagation, page-scoped defaults, rendered font size/color, marker indentation, marker gap, spacing-after rhythm, fresh page/column spacing-before suppression, keep-together page flow, keep-with-next page flow, and proportional standard-font wrapping for wide/narrow glyph runs in top-level and row/column flows so bullets and numbering can improve without invoice-specific templates. - Panel style checks now cover snapshotting, theme propagation, page-scoped defaults, rendered background color, max-width alignment, padding, spacing rhythm including fresh page/column spacing-before suppression, and keep-with-next page flow in top-level and row/column flows so callouts can improve as reusable boxed paragraphs instead of report-specific widgets. -- Row/column visual-quality checks now cover ordinary Word-like column primitives, asserting that extracted text lines remain inside their column frames, preserve explicit/default gutter clearance, keep readable baseline rhythm plus row-level breathing room, suppress flow-object spacing-before at column starts, and move kept rows together instead of splitting awkwardly at the bottom of a page. +- Row/column visual-quality checks now cover ordinary Word-like column primitives, asserting that extracted text lines remain inside their column frames, preserve explicit/default gutter clearance, render optional column separator strokes, keep readable baseline rhythm plus row-level breathing room, suppress flow-object spacing-before at column starts, and move kept rows together instead of splitting awkwardly at the bottom of a page. - Table visual-quality checks cover proportional standard-font cell wrapping for wide and narrow glyph runs in top-level and row/column flows plus long unspaced token wrapping so prefixed identifiers and generated IDs stay inside the page content frame. - Table visual-quality checks cover right alignment for common report numbers in top-level and row/column table flows, including currency symbols, percentages, and parenthesized negative/accounting values. - Generic line-item visual gates now verify Word-like table primitives with weighted/min-width columns, wrapped product text, separated numeric columns, footer/summary row separation, margin containment, and follow-on rhythm without introducing invoice-specific engine APIs. @@ -216,7 +216,7 @@ For reading and manipulation: - The professional report visual gate now includes a shared `OfficeIMO.Drawing` gradient ribbon with a simple shadow plus a translucent PNG status badge, with content-stream shading/graphics-state/soft-mask signals, so polished report fixtures exercise reusable drawing and image-alpha paths instead of only flat PDF-only shapes. - Alignment guardrails reject unsupported heading, list, image, shape, drawing-scene, panel-box, table, caption, header, and footer alignment values before layout; table alignment is guarded at model construction across top-level, compose, and link-enabled APIs; mutable header, footer, panel-box, table-caption, and table column alignment properties reject unsupported values on assignment; table alignment list assignments snapshot caller collections; compose page blocks expose read-only content block collections; paragraph and panel paragraph blocks snapshot rich text runs into read-only model collections; list blocks snapshot caller items into read-only model collections; panel paragraph blocks snapshot caller styles at add time; paragraph and panel text alignment preserves supported justification and rejects invalid enum state. -`OfficeIMO.Word.Pdf` currently uses QuestPDF/SkiaSharp and can export selected Word content. This is useful as a bridge, but it is not the desired final engine. +`OfficeIMO.Word.Pdf` now uses the first-party `OfficeIMO.Pdf` engine for Word-to-PDF export, and the old QuestPDF/SkiaSharp engine selection has been removed from the package surface. `OfficeIMO.Drawing` is allowed and encouraged as the shared first-party engine for colors, image metadata, image fitting, font metadata, text measurement, reusable drawing primitives, and eventually office-wide drawing scene concepts. The PDF engine should reuse it where the concept is office-wide instead of growing PDF-only copies of the same primitives; PDF-specific serialization, page objects, and layout decisions should stay in `OfficeIMO.Pdf`. As the PDF visual layer grows, prefer lifting reusable drawing behavior into `OfficeIMO.Drawing` when Word, Excel, PowerPoint, Visio, or future OfficeIMO packages can consume it too. @@ -249,7 +249,7 @@ Add a repo-local visual test harness that is used for development and CI. - Store small, stable baselines for core scenarios. - Emit expected, actual, and diff PNG artifacts for raster failures. - Keep rasterizer tooling in test/dev infrastructure, not as runtime dependencies of `OfficeIMO.Pdf`. -- Initial Poppler-backed PNG approval exists for the professional report, a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, plus compact hello-world, core-layout, style-cheatsheet, styled-runs, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers scenarios in `PdfDocRasterVisualBaselineTests`. These fixtures deliberately exercise generic layout primitives rather than introducing template-specific engine APIs. It is intentionally a test/dev lane: set `OFFICEIMO_REQUIRE_PDF_RASTERIZER=1` to make missing `pdftoppm` fail, `OFFICEIMO_UPDATE_PDF_RASTER_BASELINE=1` to approve refreshed PNGs, `OFFICEIMO_PDF_RASTER_PIXEL_TOLERANCE` for per-channel tolerance, and `OFFICEIMO_PDF_RASTER_ALLOWED_DIFF_PIXELS` for limited changed-pixel allowance. +- Initial Poppler-backed PNG approval exists for the professional report, a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, a native Word-to-first-party-PDF report fixture, a native Word daily-layout fixture covering TOC, margins, page background color, columns, separator lines, fonts, colors, lists, links, images, headers/footers, and a table inside the column flow, a native Word table-cell picture-control fixture, a native Excel daily-workbook fixture covering worksheet headers/footers, margins/orientation, merged cells, number formats, explicit row/column sizing, hidden row/column filtering, internal/external hyperlinks, and worksheet/header images, plus compact hello-world, core-layout, style-cheatsheet, styled-runs, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers scenarios in `PdfDocRasterVisualBaselineTests`. These fixtures deliberately exercise generic layout primitives rather than introducing template-specific engine APIs. It is intentionally a test/dev lane: set `OFFICEIMO_REQUIRE_PDF_RASTERIZER=1` to make missing `pdftoppm` fail, `OFFICEIMO_UPDATE_PDF_RASTER_BASELINE=1` to approve refreshed PNGs, `OFFICEIMO_PDF_RASTER_PIXEL_TOLERANCE` for per-channel tolerance, and `OFFICEIMO_PDF_RASTER_ALLOWED_DIFF_PIXELS` for limited changed-pixel allowance. Initial visual baseline scenarios: @@ -281,7 +281,7 @@ Make the intent visible and prevent accidental dependency creep. - Update `OfficeIMO.Pdf` README to describe current features accurately. - Document the split between `OfficeIMO.Pdf` and `OfficeIMO.Word.Pdf`. -- Add package guardrails that fail if `OfficeIMO.Pdf` gains runtime package dependencies. +- Add package guardrails that fail if `OfficeIMO.Pdf` or the native `OfficeIMO.Word.Pdf` adapter gains runtime package dependencies. - Add a public support matrix for create/read/manipulate/export capabilities. - Add examples that produce professional-looking output, not toy PDFs. @@ -289,7 +289,7 @@ Exit criteria: - Users can understand what `OfficeIMO.Pdf` is today. - Contributors can understand where it is going. -- The dependency-free promise is tested. +- The dependency-free promise is tested for the core PDF package and the native Word PDF adapter. ### 1. Visual Foundation @@ -302,8 +302,8 @@ Fix the visible quality of the current builder before expanding the surface. - Panel scalar style setters now reject invalid border width, padding, max width, and outer spacing on assignment while layout-dependent padding conflicts remain render-time diagnostics; panel paragraphs participate in flow rhythm through `PanelStyle.SpacingBefore` and `PanelStyle.SpacingAfter`. - Fix table overflow with clipping, wrapping, or diagnostics. Initial table cell and caption wrapping now uses proportional standard-font glyph metrics instead of average character widths, so wide glyphs wrap before crowding cell edges and narrow glyphs do not over-wrap. - Add table column width rules: fixed, relative, auto, min/max. Initial fixed widths exist through `PdfTableStyle.ColumnWidthPoints`, min/max constraints through `PdfTableStyle.ColumnMinWidthPoints` and `ColumnMaxWidthPoints`, relative weights through `PdfTableStyle.ColumnWidthWeights`, and content-aware auto-fit sizing through `PdfTableStyle.AutoFitColumns` backed by `OfficeIMO.Drawing.OfficeTextMeasurer` with standard-font token minimums for wrapping stability; column sizing list setters now reject non-positive/non-finite intrinsic values and snapshot assigned collections; non-null fixed/min/max entries and relative weights outside the actual table grid fail during table layout/preflight; rendered visual gates cover fixed, relative, min/max, and content-aware column sizing in top-level and row/column table flows. -- Add table cell padding, borders, background, vertical alignment, text alignment, captions, typography, vertical spacing, and page-flow policies. Initial symmetric and side-specific cell padding exists through `PdfTableStyle.CellPaddingX`, `CellPaddingY`, `CellPaddingLeft`, `CellPaddingRight`, `CellPaddingTop`, and `CellPaddingBottom`; initial side-specific per-cell border overrides exist through `PdfTableStyle.CellBorders` / `PdfCellBorder`; initial body column background fills exist through `PdfTableStyle.BodyColumnFills`; initial absolute per-cell fills exist through `PdfTableStyle.CellFills`; explicit cell fills and borders now use combined row height for simple row-spanned cells and rectangular merged cells in top-level and row/column flows, explicit fill/border coordinates outside the table grid fail with clear diagnostics, and explicit fill/border coordinates targeting row-span or column-span continuation slots are skipped because those grid positions are occupied by the spanning cell; row striping and header/footer row fills skip continuation columns occupied by row-spanned cells from previous rows; body column fills skip continuation columns occupied by row-spanned or column-spanned cells; non-null body-column fills plus horizontal and vertical alignments outside the actual table grid fail during table layout/preflight; initial table captions exist through `PdfTableStyle.Caption`; initial table left indentation exists through `PdfTableStyle.LeftIndent`; initial table width caps exist through `PdfTableStyle.MaxWidth`; both honor left/center/right placement in top-level and row/column flows; initial `PdfTableCell` column spans render across combined column widths in top-level and row/column flows; initial `PdfTableCell.RowSpan` support lets simple vertically merged and rectangular merged cells occupy following-row grid columns, use combined row height, honor horizontal/vertical alignment inside the combined box, reject row spans that extend beyond the available table rows, reject row spans that cross resolved header/body/footer boundaries, and keep row/header/footer separators plus default table border grids from crossing the merged cell interior in top-level and row/column flows, including rectangular merged-cell vertical-grid gaps on row-span continuation rows; `PdfTableCell` can own URI link metadata so linked column/row-spanned and rectangular merged cells emit annotations over the merged text frame; initial table spacing exists through `PdfTableStyle.SpacingBefore` and `PdfTableStyle.SpacingAfter`; initial table keep-together and keep-with-next page-flow exists through `PdfTableStyle.KeepTogether` and `PdfTableStyle.KeepWithNext` for top-level and row/column tables; table keep-with-next first-row placement estimates use the same header/footer row-count, explicit cell-style coordinate bounds, column-scoped style bounds, configured column widths, and row-span boundary validation as rendering; initial oversized-row page splitting policy exists through `PdfTableStyle.AllowRowBreakAcrossPages`; initial table typography controls exist through generic header/body/footer font-size settings, cell line height, plus header/footer bold toggles; initial row/header/footer separator controls exist through `RowSeparatorColor` / `RowSeparatorWidth`, `HeaderSeparatorColor` / `HeaderSeparatorWidth`, and `FooterSeparatorColor` / `FooterSeparatorWidth`; Word-like table presets and `PdfTheme.WordLike()` include footer separator defaults for summary/footer rows, supported Word table style names include `TableNormal` plus Accent1-6 variants with Word default theme border, separator, and soft band colors for the existing light grid/list presets, and `TableStyles.CanonicalWordStyleNames` plus canonical-name helpers separate clean display/storage names from accepted alias spellings; table cell text that would escape its cell box is clipped to the cell rectangle at the PDF content-stream level with a small antialiasing tolerance; oversized caption-plus-first-row combinations fail with a clear layout diagnostic in top-level and row/column table flows; document default table styles exist through `PdfOptions.DefaultTableStyle` and `PdfDoc.DefaultTableStyle(...)`, page-scoped defaults through `PdfPageCompose.DefaultTableStyle(...)`, including supported Word table style names; table model/style validation now rejects invalid border width, padding, max width, left indent, spacing, captions, unsupported table/caption justification, alignment enum values, negative or oversized header/footer row counts, row height, row baseline offsets, font sizes, line height, negative or out-of-grid cell-fill/border coordinates, out-of-grid column-scoped style entries, invalid column spans, invalid, overlong, or boundary-crossing row spans, invalid cell link URIs, impossible column sizing, kept tables taller than the available page content height, and oversized rows when row splitting is disabled; table blocks snapshot rows, explicit cells including span metadata, styles, and link dictionaries into read-only model state; scalar setters reject invalid intrinsic values on assignment; and alignment, column-sizing, fill, border, and default-style collection setters snapshot assigned collections. -- Add deterministic visual baselines for all current features. Initial text geometry snapshots now cover representative and professional report fixtures, mixed Word-like flow rhythm across headings, paragraphs, panels, lists, tables, images, shapes, and row columns with no-cramped-baseline, same-baseline text-collision, and ambiguous-run-gap guards, row/column text-frame rhythm and gutter clearance, generic line-item table rhythm, the two-page line-item statement fixture, plus content-stream signals for images, PNG alpha soft masks, clipping, and vector drawing; initial Poppler raster PNG approval now covers the professional report reference, a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, plus compact hello-world, core-layout, style-cheatsheet, styled-runs, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers fixtures. Business-shaped fixtures should remain proof documents for the generic Word-like primitives rather than new invoice-specific surface area. +- Add table cell padding, borders, background, vertical alignment, text alignment, captions, typography, vertical spacing, and page-flow policies. Initial symmetric and side-specific cell padding exists through `PdfTableStyle.CellPaddingX`, `CellPaddingY`, `CellPaddingLeft`, `CellPaddingRight`, `CellPaddingTop`, and `CellPaddingBottom`; initial per-cell padding overrides exist through `PdfTableStyle.CellPaddings` / `PdfCellPadding` and participate in wrapping, row height, clipping, and link annotation geometry in top-level and row/column table flows; initial cell spacing exists through `PdfTableStyle.CellSpacing` in top-level and row/column table flows; initial side-specific per-cell border overrides with independent side colors, widths, and solid/dashed/dotted/dash-dot strokes exist through `PdfTableStyle.CellBorders` / `PdfCellBorder` / `PdfCellBorderSide`; initial body column background fills exist through `PdfTableStyle.BodyColumnFills`; initial absolute per-cell fills exist through `PdfTableStyle.CellFills`; initial per-cell horizontal/vertical alignment overrides exist through `PdfTableStyle.CellAlignments` and `PdfTableStyle.CellVerticalAlignments`; explicit cell fills and borders now use combined row height for simple row-spanned cells and rectangular merged cells in top-level and row/column flows, explicit fill/border/padding/alignment coordinates outside the table grid fail with clear diagnostics, and explicit fill/border coordinates targeting row-span or column-span continuation slots are skipped because those grid positions are occupied by the spanning cell; row striping and header/footer row fills skip continuation columns occupied by row-spanned cells from previous rows; body column fills skip continuation columns occupied by row-spanned or column-spanned cells; non-null body-column fills plus horizontal and vertical alignments outside the actual table grid fail during table layout/preflight; initial table captions exist through `PdfTableStyle.Caption`; initial table left indentation exists through `PdfTableStyle.LeftIndent`; initial table width caps exist through `PdfTableStyle.MaxWidth`; both honor left/center/right placement in top-level and row/column flows; initial `PdfTableCell` column spans render across combined column widths in top-level and row/column flows; initial `PdfTableCell.RowSpan` support lets simple vertically merged and rectangular merged cells occupy following-row grid columns, use combined row height, honor horizontal/vertical alignment inside the combined box, reject row spans that extend beyond the available table rows, reject row spans that cross resolved header/body/footer boundaries, and keep row/header/footer separators plus default table border grids from crossing the merged cell interior in top-level and row/column flows, including rectangular merged-cell vertical-grid gaps on row-span continuation rows; `PdfTableCell` can own URI link metadata so linked column/row-spanned and rectangular merged cells emit annotations over the merged text frame; initial table spacing exists through `PdfTableStyle.SpacingBefore` and `PdfTableStyle.SpacingAfter`; initial table keep-together and keep-with-next page-flow exists through `PdfTableStyle.KeepTogether` and `PdfTableStyle.KeepWithNext` for top-level and row/column tables; table keep-with-next first-row placement estimates use the same header/footer row-count, explicit cell-style coordinate bounds, column-scoped style bounds, configured column widths, and row-span boundary validation as rendering; initial table-wide and per-row minimum heights exist through `PdfTableStyle.MinRowHeight` and `PdfTableStyle.RowMinHeights`; initial oversized-row page splitting policy exists through `PdfTableStyle.AllowRowBreakAcrossPages` with per-row overrides through `PdfTableStyle.RowAllowBreakAcrossPages`; initial table typography controls exist through generic header/body/footer font-size settings, cell line height, plus header/footer bold toggles; initial row/header/footer separator controls exist through `RowSeparatorColor` / `RowSeparatorWidth`, `HeaderSeparatorColor` / `HeaderSeparatorWidth`, and `FooterSeparatorColor` / `FooterSeparatorWidth`; Word-like table presets and `PdfTheme.WordLike()` include footer separator defaults for summary/footer rows, supported Word table style names include `TableNormal` plus Accent1-6 variants with Word default theme border, separator, and soft band colors for the existing light grid/list presets, and `TableStyles.CanonicalWordStyleNames` plus canonical-name helpers separate clean display/storage names from accepted alias spellings; table cell text that would escape its cell box is clipped to the cell rectangle at the PDF content-stream level with a small antialiasing tolerance; oversized caption-plus-first-row combinations fail with a clear layout diagnostic in top-level and row/column table flows; document default table styles exist through `PdfOptions.DefaultTableStyle` and `PdfDoc.DefaultTableStyle(...)`, page-scoped defaults through `PdfPageCompose.DefaultTableStyle(...)`, including supported Word table style names; table model/style validation now rejects invalid border width, padding, cell spacing, max width, left indent, spacing, captions, unsupported table/caption justification, alignment enum values, negative or oversized header/footer row counts, row height, row baseline offsets, font sizes, line height, negative or out-of-grid cell-fill/border/padding/alignment coordinates, out-of-grid row-minimum-height and row-break-policy entries, out-of-grid column-scoped style entries, invalid column spans, invalid, overlong, or boundary-crossing row spans, invalid cell link URIs, impossible column sizing, kept tables taller than the available page content height, and oversized rows when row splitting is disabled; table blocks snapshot rows, explicit cells including span metadata, styles, and link dictionaries into read-only model state; scalar setters reject invalid intrinsic values on assignment; and alignment, row-height, row-break-policy, column-sizing, fill, border, padding, and default-style collection setters snapshot assigned collections. +- Add deterministic visual baselines for all current features. Initial text geometry snapshots now cover representative and professional report fixtures, mixed Word-like flow rhythm across headings, paragraphs, panels, lists, tables, images, shapes, and row columns with no-cramped-baseline, same-baseline text-collision, and ambiguous-run-gap guards, row/column text-frame rhythm and gutter clearance, generic line-item table rhythm, the two-page line-item statement fixture, plus content-stream signals for images, PNG alpha soft masks, clipping, and vector drawing; initial Poppler raster PNG approval now covers the professional report reference, a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, the first native Word-to-first-party-PDF report fixture, a native Word daily-layout fixture, a native Word table-cell picture-control fixture, and a native Excel daily-workbook fixture, plus compact hello-world, core-layout, style-cheatsheet, styled-runs, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers fixtures. Business-shaped fixtures should remain proof documents for the generic Word-like primitives rather than new invoice-specific surface area. Exit criteria: @@ -356,11 +356,11 @@ Exit criteria: Build the engine that can eventually replace QuestPDF for OfficeIMO scenarios. - Blocks: paragraph, heading, bulleted list, numbered list, table, image, canvas, panel. -- Inline content: runs, links, line breaks, spans, superscript/subscript. Initial explicit rich paragraph line breaks exist through `PdfParagraphBuilder.LineBreak()` and newline normalization in text runs; tabs inside rich paragraph runs are treated as Word-like word spacing so raw tab control bytes do not reach PDF text-show operators; superscript/subscript run placement exists through `TextRun.Baseline`, `TextRun.Superscript(...)`, `TextRun.Subscript(...)`, and matching `PdfParagraphBuilder` helpers, with scaled measurement and PDF text-rise output; paragraph and panel text alignment reject invalid enum state while preserving justification, and heading, bullet list, numbered list, image, shape, and drawing-scene blocks reject unsupported alignment values before layout. +- Inline content: runs, links, line breaks, spans, superscript/subscript. Initial explicit rich paragraph line breaks exist through `PdfParagraphBuilder.LineBreak()` and newline normalization in text runs; scoped run font sizes exist through `TextRun.FontSize`, `PdfParagraphBuilder.FontSize(...)`, and `ResetFontSize()` with matching wrap measurement and PDF font operators; scoped run background/highlight fills exist through `TextRun.BackgroundColor`, `PdfParagraphBuilder.BackgroundColor(...)`, and `ResetBackgroundColor()` with background rectangles emitted before text; tabs inside rich paragraph runs are treated as Word-like word spacing so raw tab control bytes do not reach PDF text-show operators; superscript/subscript run placement exists through `TextRun.Baseline`, `TextRun.Superscript(...)`, `TextRun.Subscript(...)`, and matching `PdfParagraphBuilder` helpers, with scaled measurement and PDF text-rise output; paragraph and panel text alignment reject invalid enum state while preserving justification, and heading, bullet list, numbered list, image, shape, and drawing-scene blocks reject unsupported alignment values before layout. - Page flow: automatic pagination, page breaks, keep-with-next, keep-together, widow/orphan control. Initial long rich paragraph continuation across pages exists for top-level flow paragraphs, paragraph keep-together, keep-with-next, and widow/orphan control exist for top-level and row/column flows, headings avoid being orphaned from following visible flow neighbors in top-level and row/column flows, bullet/numbered lists, panel paragraphs, and horizontal rules can keep with the following visible block when they fit the page frame, lists and panels can keep together, and oversized bullet/numbered list items now continue across pages without crossing the bottom margin. -- Sections: page size, orientation, margins. Initial options-level, document-default, page-scoped, and section-scoped flow size, portrait/landscape orientation helpers, scalar margins, and reusable Word-compatible margin presets exist through `PdfOptions.PageSize`, `PdfOptions.Margins`, `PdfDoc.Size(...)`, `PdfDoc.Orientation(...)`, `PdfDoc.Portrait()`, `PdfDoc.Landscape()`, `PdfDoc.Margin(...)`, `PdfDoc.Margin(PageMargins)`, top-level `PdfDoc.Page(...)` / `PdfDoc.Section(...)`, `PdfDoc.Compose(...Page...)` / `Compose(...Section...)`, matching `PdfPageCompose` methods, and `PageMargins`; richer section inheritance and mid-page section breaks remain roadmap work. -- Headers and footers. Initial simple page header/footer text exists through `PdfOptions`, document-level `PdfDoc.Header(...)` / `PdfDoc.Footer(...)`, and page-scoped `PdfPageCompose.Header(...)` / `PdfPageCompose.Footer(...)`, with literal text formats, `{page}` / `{pages}` tokens, composed text/token segment builders for headers and footers, alignment, font, size, text color, margin-relative offsets with placement validation, document first-page header/footer overrides, and odd/even page overrides for Word-like cover-page/report flows. -- Multi-column flow. +- Sections: page size, orientation, margins, and page background color. Initial options-level, document-default, page-scoped, and section-scoped flow size, portrait/landscape orientation helpers, scalar margins, reusable Word-compatible margin presets, and full-page background fills exist through `PdfOptions.PageSize`, `PdfOptions.Margins`, `PdfOptions.BackgroundColor`, `PdfDoc.Size(...)`, `PdfDoc.Orientation(...)`, `PdfDoc.Portrait()`, `PdfDoc.Landscape()`, `PdfDoc.Margin(...)`, `PdfDoc.Margin(PageMargins)`, `PdfDoc.Background(...)`, top-level `PdfDoc.Page(...)` / `PdfDoc.Section(...)`, `PdfDoc.Compose(...Page...)` / `Compose(...Section...)`, matching `PdfPageCompose` methods, and `PageMargins`; richer section inheritance, mid-page section breaks, and image/background-shape page fills remain roadmap work. +- Headers and footers. Initial simple page header/footer text, images, and shared drawing shapes exist through `PdfOptions`, document-level `PdfDoc.Header(...)` / `PdfDoc.Footer(...)`, and page-scoped `PdfPageCompose.Header(...)` / `PdfPageCompose.Footer(...)`, with literal text formats, `{page}` / `{pages}` tokens, composed text/token segment builders for headers and footers, left/center/right text zones, simple aligned image and shape placement, alignment, font, size, text color, margin-relative offsets with placement validation, document first-page header/footer overrides, and odd/even page overrides for Word-like cover-page/report flows. +- Multi-column flow. Initial reusable row/column composition exists, and Word section columns with explicit column breaks, inline paragraph column breaks, explicit unequal section column widths, plus a heading/keep-with-next-aware automatic distribution fallback for sections without explicit breaks map through it; richer balanced newspaper-style text flow remains roadmap work. - Absolute positioning escape hatch. - Overflow diagnostics. Initial page setup diagnostics reject invalid intrinsic page sizes and margins at fluent assignment time, while render-time page option diagnostics reject invalid default/header/footer font selections and font sizes, header/footer alignment/placement, and impossible content frames; `PdfDoc.Create(options)` snapshots caller-provided options before rendering. Initial fixed-size flow block diagnostics exist for images, horizontal rules, vector shapes, and drawing scenes that exceed the available page content width or height; image blocks snapshot caller-provided bytes and validate intrinsic model state, while image, drawing, and horizontal rule styles validate intrinsic spacing at block construction; row/column composition rejects empty rows plus invalid gutters, non-finite, non-positive, over-100%, and over-allocated column widths before rendering, rejects gutters that exceed the available content width during render, and exposes read-only row/column model collections after composition; kept-together paragraphs and panels also report when their measured height exceeds the available page content height, and panel styles validate border width, padding, outer spacing, max width, panel-box alignment, and text-frame viability. - Layout debug overlays. @@ -374,22 +374,22 @@ Exit criteria: Tables need to be a flagship feature because PowerShell reporting depends on them. -- Header rows. Initial configurable leading header row count exists through `PdfTableStyle.HeaderRowCount`. -- Repeating headers across pages. Initial support exists for generated simple tables, including configured multi-row headers when they fit on the page. +- Header rows. Initial configurable leading visual header row count exists through `PdfTableStyle.HeaderRowCount`; optional `PdfTableStyle.RepeatHeaderRowCount` separates visual header styling from page-break repetition while preserving the existing repeat-all-header-rows default when unset. +- Repeating headers across pages. Initial support exists for generated simple tables, including configured multi-row headers when they fit on the page; the native Word-to-PDF path maps contiguous leading Word repeat-header rows to `PdfTableStyle.RepeatHeaderRowCount` and keeps Word first-row table styling as visual `HeaderRowCount` without forcing repetition. - Footer rows. Initial trailing footer row count exists through `PdfTableStyle.FooterRowCount`, with footer fill/text styling and optional footer separators above the first footer row. - Row striping. Initial body row striping is computed relative to the first body row and does not let configured header rows shift the stripe pattern. - Table captions. Initial caption text, alignment, color, font size, and spacing exist through `PdfTableStyle.Caption`, `CaptionAlign`, `CaptionColor`, `CaptionFontSize`, and `CaptionSpacingAfter`, with rendered visual gates for top-level and row/column table flows. -- Cell padding. Initial symmetric and side-specific cell padding exists through `PdfTableStyle.CellPaddingX`, `CellPaddingY`, `CellPaddingLeft`, `CellPaddingRight`, `CellPaddingTop`, and `CellPaddingBottom`. +- Cell padding. Initial symmetric and side-specific cell padding exists through `PdfTableStyle.CellPaddingX`, `CellPaddingY`, `CellPaddingLeft`, `CellPaddingRight`, `CellPaddingTop`, and `CellPaddingBottom`; per-cell padding overrides exist through `PdfTableStyle.CellPaddings` / `PdfCellPadding`. - Table spacing before and after. Initial spacing exists through `PdfTableStyle.SpacingBefore` and `PdfTableStyle.SpacingAfter`. -- Cell border model. Initial side-specific per-cell border overrides exist through `PdfTableStyle.CellBorders` / `PdfCellBorder`; assigned border dictionaries and border values are snapshotted, negative border coordinates fail on assignment, out-of-grid border coordinates fail during table layout/preflight, `PdfCellBorder.Width` rejects invalid intrinsic widths on assignment, row-spanned explicit cell borders use combined row height, explicit border coordinates skip row-span and column-span continuation slots, default table border grids skip row-spanned and rectangular merged-cell interiors in top-level and row/column table flows, and rendered content-stream gates cover top-level and row/column table flows. Richer border conflict resolution and collapse/merge behavior remains roadmap work. -- Cell background. Initial body column fills exist through `PdfTableStyle.BodyColumnFills`; initial absolute per-cell fills exist through `PdfTableStyle.CellFills`; assigned fill collections are snapshotted, negative cell-fill coordinates fail on assignment, out-of-grid fill coordinates fail during table layout/preflight, row-spanned explicit cell fills use combined row height, explicit fill coordinates skip row-span and column-span continuation slots, body column fills skip continuation columns occupied by row-spanned cells from previous rows or column-spanned cells in the current row, and rendered content-stream gates cover top-level and row/column table flows. +- Cell border model. Initial side-specific per-cell border overrides exist through `PdfTableStyle.CellBorders` / `PdfCellBorder`; independent side colors, widths, solid/dashed/dotted/dash-dot stroke styles, two-line borders, and diagonal-up/diagonal-down cell lines exist through `PdfCellBorderSide` and `PdfCellBorder`; assigned border dictionaries and border values are snapshotted, negative border coordinates fail on assignment, out-of-grid border coordinates fail during table layout/preflight, `PdfCellBorder.Width` and `PdfCellBorderSide.Width` reject invalid intrinsic widths on assignment, row-spanned explicit cell borders use combined row height, explicit border coordinates skip row-span and column-span continuation slots, default table border grids skip row-spanned and rectangular merged-cell interiors in top-level and row/column table flows, and rendered content-stream gates cover top-level and row/column table flows. Richer border conflict resolution and collapse/merge behavior remain roadmap work. +- Cell background, data bars, and icons. Initial body column fills exist through `PdfTableStyle.BodyColumnFills`; initial absolute per-cell fills exist through `PdfTableStyle.CellFills`; proportional in-cell data bars exist through `PdfTableStyle.CellDataBars` / `PdfCellDataBar`; small in-cell vector indicators exist through `PdfTableStyle.CellIcons` / `PdfCellIcon`; assigned fill, data-bar, and icon collections are snapshotted, negative cell-fill/data-bar/icon coordinates fail on assignment, out-of-grid fill/data-bar/icon coordinates fail during table layout/preflight, invalid data-bar ratios and icon sizes fail on assignment, row-spanned explicit cell fills use combined row height, explicit fill/data-bar/icon coordinates skip row-span and column-span continuation slots, body column fills skip continuation columns occupied by row-spanned cells from previous rows or column-spanned cells in the current row, and rendered content-stream gates cover top-level and row/column table flows. - Row striping. Initial body row striping exists through `PdfTableStyle.RowStripeFill`; stripe, header, and footer row fills skip continuation columns occupied by row-spanned cells from previous rows; rendered content-stream gates verify stripes are calculated relative to the first body row, do not apply to configured header rows, and stay out of row-spanned cell interiors in top-level and row/column table flows. - Row separators. Initial body row separators exist through `PdfTableStyle.RowSeparatorColor` / `RowSeparatorWidth`; initial header separators exist through `PdfTableStyle.HeaderSeparatorColor` / `HeaderSeparatorWidth`; initial footer separators exist through `PdfTableStyle.FooterSeparatorColor` / `FooterSeparatorWidth`; row/header/footer separators skip row-spanned cell interiors in top-level and row/column table flows; Word-like table presets and `PdfTheme.WordLike()` provide neutral footer separator defaults; rendered content-stream gates cover top-level and row/column table flows. - Column width strategies. Initial table left indentation exists through `PdfTableStyle.LeftIndent`; initial table max-width caps exist through `PdfTableStyle.MaxWidth`; fixed column widths exist through `PdfTableStyle.ColumnWidthPoints`, min/max constraints through `PdfTableStyle.ColumnMinWidthPoints` and `ColumnMaxWidthPoints`, relative column width weights through `PdfTableStyle.ColumnWidthWeights`, and content-aware auto-fit sizing through `PdfTableStyle.AutoFitColumns`; rendered visual gates cover top-level and row/column table flows. -- Row height strategies. Initial minimum row height exists through `PdfTableStyle.MinRowHeight`. -- Text wrapping in cells. -- Cell alignment. Initial horizontal alignment exists through `PdfTableStyle.Alignments`; initial vertical alignment exists through `PdfTableStyle.VerticalAlignments`; both are honored in top-level and row/column table flows, reject out-of-grid column entries during table layout/preflight, and include rectangular merged cells that align text inside the combined box. -- Row/page break policies. Initial row-by-row pagination, configurable oversized-row line splitting, and keep-together page-flow exist for generated simple tables, including row/column flows when the kept table or split row segment fits the page frame. +- Row height strategies. Initial table-wide minimum row height exists through `PdfTableStyle.MinRowHeight`; non-uniform per-row minimum heights exist through `PdfTableStyle.RowMinHeights` and are honored in top-level and row/column table flows. +- Text wrapping in cells. Initial rich `PdfTableCell` text runs now reuse the rich paragraph engine for scoped color, font size, highlight/background, baseline, tabs, links, and basic text decorations in table cells. +- Cell alignment. Initial column horizontal alignment exists through `PdfTableStyle.Alignments`; initial column vertical alignment exists through `PdfTableStyle.VerticalAlignments`; non-uniform per-cell overrides exist through `PdfTableStyle.CellAlignments` and `PdfTableStyle.CellVerticalAlignments`; all are honored in top-level and row/column table flows, reject out-of-grid entries during table layout/preflight, snapshot assigned collections, and include rectangular merged cells that align text inside the combined box. +- Row/page break policies. Initial row-by-row pagination, configurable oversized-row line splitting, table-wide `PdfTableStyle.AllowRowBreakAcrossPages`, per-row `PdfTableStyle.RowAllowBreakAcrossPages`, and keep-together page-flow exist for generated simple tables, including row/column flows when the kept table or split row segment fits the page frame. - Colspan and rowspan. Initial `PdfTableCell.ColumnSpan` support exists for simple column spans in top-level and row/column table flows, including linked spanned cells; initial `PdfTableCell.RowSpan` support exists for simple vertically merged cells and rectangular merged cells in top-level and row/column table flows, including linked row-spanned cells; explicit fills and borders on row-spanned and rectangular merged cells now paint over the combined box, explicit fill/border coordinates skip row-span and column-span continuation slots, linked merged-cell annotations cover the combined text frame, text alignment resolves against the combined box, overlong row spans fail with a clear model-level diagnostic, row spans that cross resolved header/body/footer boundaries fail with clear diagnostics, and row/header/footer separators plus default table border grids skip the merged cell interior, including column-span interior boundaries on row-span continuation rows; richer merged-cell conflict behavior remains roadmap work. - Nested tables only after the simpler model is stable. @@ -429,7 +429,7 @@ Exit criteria: Make generated PDFs feel complete. -- Links and destinations. Initial URI link annotations exist for paragraphs, headings, images, shapes, drawing scenes, vector convenience helpers, and table cells, including escaped annotation contents metadata and linked merged-cell rectangles for column/row-spanned table cells; generic `Bookmark(...)` flow anchors emit simple `/Names` `/Dests` named destinations from top-level and row/column flows; paragraph `LinkToBookmark(...)` runs emit internal GoTo annotations targeting those named destinations with missing-target validation; `PdfReadPage` and `PdfInspector` can read simple page URI and named-destination link annotations with contents metadata; `PdfDocumentInfo` reports document-level readable link counts, distinct URI targets, distinct internal destination targets, plus a page-aware flattened link list; simple direct/names-tree named destinations are readable as named document targets; and simple destination-array plus GoTo dictionary open actions are readable as document navigation targets. Next slices should broaden destination/action support, richer keyboard/readback metadata, and preservation rules without becoming template-specific. +- Links and destinations. Initial URI link annotations exist for paragraphs, headings, images, shapes, drawing scenes, vector convenience helpers, table cells, and rich list items, including escaped annotation contents metadata and linked merged-cell rectangles for column/row-spanned table cells; `PdfTableCell` can also own named-destination link metadata and define named-destination anchors at rendered cell positions in top-level and row/column flows; generic `Bookmark(...)` flow anchors emit simple `/Names` `/Dests` named destinations from top-level and row/column flows; rich `PdfListItem` can emit per-item named destinations from top-level and row/column list flows; paragraph `LinkToBookmark(...)` runs and bookmark-targeted H1/H2/H3/table-cell links emit internal GoTo annotations targeting those named destinations with missing-target validation; `PdfReadPage` and `PdfInspector` can read simple page URI and named-destination link annotations with contents metadata; `PdfDocumentInfo` reports document-level readable link counts, distinct URI targets, distinct internal destination targets, plus a page-aware flattened link list; simple direct/names-tree named destinations are readable as named document targets; and simple destination-array plus GoTo dictionary open actions are readable as document navigation targets. Next slices should broaden destination/action support, richer keyboard/readback metadata, and preservation rules without becoming template-specific. - Bookmarks/outlines. Initial `PdfOptions.CreateOutlineFromHeadings` support writes nested PDF outlines from H1/H2/H3 blocks through a shared outline dictionary builder, and `PdfInspector` can read simple outline trees, indirect destinations, direct/name-tree named-destination targets, and simple GoTo action destinations from the trailer-root catalog. Rewrite-style manipulation preserves simple outline trees, including simple GoTo action outline entries, whose destinations point only at copied pages, drops outline trees when a selected-page operation would leave stale outline destinations, and still blocks complex non-GoTo or additional-action outline trees. - Catalog identity. Simple catalog `/Version` and `/Lang` values are readable through `PdfReadDocument` / `PdfInspector` and preserved during rewrite-style manipulation so split/merge/edit helpers do not strip document-level version or language metadata. - Viewer preferences. Simple catalog `/ViewerPreferences` dictionaries are readable through `PdfReadDocument` / `PdfInspector` as generic key/value entries with boolean helpers, and preserved during rewrite-style manipulation; complex viewer preference graphs remain blocked until richer typed models exist. @@ -478,10 +478,10 @@ Exit criteria: Add only after the object engine and page manipulation are strong. - Inspect AcroForm fields. Initial read-only simple field inventory exists through `PdfDocumentInfo.FormFields`, including fully qualified field names, field types, simple values, alternate/mapping names, and raw flags. -- Read field values. -- Set field values. Initial `PdfFormFiller.FillFields(...)` support can update simple text/string-style values, choice values supplied as export values or `/Opt` display text when available, multi-select choice arrays through `PdfFormFieldValue.FromValues(...)`, and button name values by fully qualified field name from bytes, paths, or streams, generate simple text/choice-widget normal appearance streams and simple button-widget Off/selected appearance states for widgets with `/Rect`, mark `/NeedAppearances true`, return bytes from path inputs, write path inputs to paths or caller-owned output streams, and reject signed or active-content PDFs. Choice appearances use `/Opt` display text when available while keeping the stored export value. Rich widget behavior and full appearance regeneration remain roadmap work. +- Read field values. `PdfInspector` / `PdfLogicalDocument` expose simple AcroForm text, choice, and button values, default values, option metadata, selected options, field flags, widgets, widget page numbers, and named appearance states. +- Set field values. Initial `PdfFormFiller.FillFields(...)` support can update simple text/string-style values, choice values supplied as export values or `/Opt` display text when available, multi-select choice arrays through `PdfFormFieldValue.FromValues(...)`, and button name values by fully qualified field name from bytes, paths, or streams, switch only the matching radio child widget appearance state on, generate simple text/choice-widget normal appearance streams and simple button-widget Off/selected appearance states for widgets with `/Rect`, mark `/NeedAppearances true`, return bytes from path inputs, write path inputs to paths or caller-owned output streams, and reject signed or active-content PDFs. Choice appearances use `/Opt` display text when available while keeping the stored export value. Rich widget behavior and full appearance regeneration remain roadmap work. - Flatten fields. Initial `PdfFormFiller.FlattenFields(...)` and `FillAndFlattenFields(...)` support paints simple text-widget appearances, simple choice-widget text appearances with `/Opt` display text when available for scalar or array selected values, and simple button-widget normal appearance states into page content, generating minimal button appearances when needed, removes those widget annotations, removes the AcroForm tree, can return bytes from path inputs or write path inputs to paths/caller-owned output streams, and rejects signed or active-content PDFs; rich/custom appearances, JavaScript actions, and complex form preservation remain roadmap work. -- Create simple text fields, checkboxes, scalar choice fields, and multi-select choice fields. Initial generated text fields, check boxes, and choice fields exist through `PdfDoc.TextField(...)`, `PdfDoc.CheckBox(...)`, `PdfDoc.ChoiceField(...)`, and `PdfDoc.MultiSelectChoiceField(...)`, including top-level, compose item/element, and row/column flow placement, visible normal appearance streams, catalog `/AcroForm` registration, inspector/logical readback, and fill/fill-and-flatten compatibility; radio buttons and richer appearance styling remain roadmap work. +- Create simple text fields, checkboxes, scalar choice fields, multi-select choice fields, and radio button groups. Initial generated text fields, check boxes, choice fields, vertical radio groups, and table-cell check boxes exist through `PdfDoc.TextField(...)`, `PdfDoc.CheckBox(...)`, `PdfDoc.ChoiceField(...)`, `PdfDoc.MultiSelectChoiceField(...)`, `PdfDoc.RadioButtonGroup(...)`, and `PdfTableCell.WithCheckBoxes(...)`, including top-level, compose item/element, row/column, and table-cell flow placement, visible normal appearance streams, optional `PdfFormFieldStyle` background/border/text/mark colors and border width, catalog `/AcroForm` registration with `/NeedAppearances false`, inspector/logical readback, and fill/fill-and-flatten compatibility; richer widget behavior remains roadmap work. - Preserve unsupported form structures when possible. - Detect security, form, navigation, tagged-structure, and catalog metadata markers to avoid unsafe work. Initial encryption/signature/form/outline/catalog-view-setting/page-label/catalog-name-tree/named-destination/open-action/viewer-preference/tagged-structure/XMP-metadata/catalog-URI/output-intent/embedded-file/optional-content/active-content marker probing is exposed through `PdfInspector.Probe`; `PdfInspector.Preflight` reports wrapper-friendly read/rewrite capability, diagnostics, structured `ReadBlockers` through `PdfReadBlockerKind`, and structured `RewriteBlockers` through `PdfRewriteBlockerKind`; signature, form, complex outline, complex page-label, unsupported catalog name-tree, malformed or unsupported named-destination name-tree, complex open-action dictionary, complex viewer-preference, tagged-structure, complex XMP metadata, complex catalog URI, complex output-intent, complex embedded/associated-file, complex optional-content, and active-content marker detection is exposed through `PdfInspector` and rejects rewrite-style manipulation before copying, merging, editing, metadata rewriting, stamping, or watermarking page content, while simple direct catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array open actions, simple GoTo open-action dictionaries, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, simple associated-file arrays, and simple optional-content metadata are preserved. - Signature creation is a separate later decision. @@ -496,16 +496,16 @@ Once the core layout engine is strong, make Office formats target it. #### Word To PDF -- Map Word paragraphs, runs, lists, tables, images, headers, footers, sections, page setup, links, bookmarks, footnotes, and simple shapes into the logical PDF model. -- Preserve unsupported content with warnings. +- Map Word paragraphs, runs, lists, tables, images, headers, footers, sections, page setup, links, bookmarks, footnotes/endnotes, text boxes, simple shapes, simple inline text content controls, simple body/table-cell check boxes, simple body/table-cell dropdown/combo/date controls, simple body/table-cell/header/footer picture content controls, and simple body/table-cell/header/footer repeating-section text items into the logical PDF model. The default `OfficeIMO.Word.Pdf` path maps basic sections, page setup through first-party `OfficeIMOPageSize` / `OfficeIMOMargins` options with explicit PDF page geometry preserved unless `PdfSaveOptions.Orientation` is set, Word document background color, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, page breaks, headings including linked headings, paragraphs/runs with isolated run color, font-size, superscript/subscript baseline, justified paragraph alignment, text-wrapping breaks, and highlight/background state, paragraph spacing/indents, simple tab stops with leaders/alignment, keep-with-next/keep-lines/widow-control flags, simple shaded and uniform/non-uniform bordered paragraphs, Word horizontal lines and paragraph top/bottom border rules, simple level-0 bullet/decimal lists with rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, footnote/endnote markers, simple tables with supported Word table style presets, rich text runs inside table cells, default and per-cell table margins, table cell spacing, table-level borders, uniform/non-uniform, double, and diagonal cell borders, uniform and non-uniform row heights, row-level break policies, preferred DXA table widths that can fit into narrower section-column frames, explicit autofit-to-contents tables, cell fills, left/center/right table placement, uniform column and non-uniform per-cell horizontal/vertical alignment, simple merged cells, separated first-row visual table styling and repeated leading table header rows, and linked cells including linked merged cells, paragraph-aligned images, simple VML shapes plus the DrawingML preset flow shapes exposed by `WordShape`, simple body text boxes rendered through first-party panel paragraphs, simple body, table-cell, header, and footer picture content controls rendered as first-party PDF images, simple body repeating sections rendered as ordinary first-party PDF paragraphs, simple table-cell repeating sections rendered as first-party rich table-cell text, simple header/footer repeating sections rendered as first-party zone text, simple header/footer text boxes with extractable text routed through first-party zones, simple inline body/table/header/footer text content controls, simple body-level and table-cell Word check boxes mapped to first-party PDF AcroForm check boxes with readback and Poppler raster-baseline coverage in the native Word report fixture, simple body-level and table-cell Word dropdown, combo box, and date picker content controls mapped to first-party PDF AcroForm choice/text fields with readback, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers mapped as static first-party zone text, simple body/table-cell/header/footer OMML equation text mapped as static first-party text, simple default/first/even header and footer text/images/shapes with left/center/right paragraph alignment, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, and simple header/footer table-cell text/images/shapes mapped to first-party zones, simple footnote/endnote markers with end-of-section note text, metadata, and page-number footer settings including Word section page-number starts/styles. +- Preserve unsupported content with warnings. Initial `PdfSaveOptions.Warnings` diagnostics exist for native Word-to-PDF export, covering unsupported header/footer visual content such as shapes without supported geometry, text boxes without extractable text, SmartArt, equations without extractable text, content controls that are not simple text, picture, static form-control text, or repeating-section mappings, and embedded documents, plus body-level SmartArt, equations without extractable text, content controls that are not mapped to simple text, checkbox, form-field, picture, or repeating-section primitives, embedded documents, and unsupported body elements that the current mapping would otherwise skip silently. - Add visual regression samples for Word-authored and OfficeIMO-authored documents. -- Replace QuestPDF-backed `OfficeIMO.Word.Pdf` when output quality is comparable for supported scenarios. +- Continue improving the first-party `OfficeIMO.Word.Pdf` exporter until supported scenarios have comparable visual quality. #### Excel To PDF -- Start with OfficeIMO-generated report sheets. -- Support page setup, margins, orientation, print area, headers/footers, tables, merged cells, styles, images, and chart snapshots. -- Add clear diagnostics for unsupported workbook features. +- Initial `OfficeIMO.Excel.Pdf` package surface exists. It exports selected or all visible workbook worksheets through `OfficeIMO.Excel` readers into first-party `OfficeIMO.Pdf` headings, images, bookmarks, chart drawing snapshots, and tables, can return bytes or write paths/streams, and supports worksheet print areas, worksheet orientation, worksheet margins, hidden workbook worksheet filtering for default all-sheet exports, hidden worksheet rows and columns, repeated print-title rows as PDF table headers, manual worksheet row and column page breaks as explicit PDF page breaks with repeated header/title rows preserved across split table chunks, simple worksheet header/footer text zones with first-page and even-page text variants plus page-number/page-count/sheet-name/date/time/workbook-file/workbook-path tokens, simple line-level font family/style, font size, and RGB text color when representable by one first-party PDF header/footer line style, and supported header/footer images, worksheet merged cells mapped to PDF table spans, supported worksheet drawing images anchored into exported PDF table cells when the anchor cell is exported and otherwise emitted as PDF flow images in anchor order, supported column/bar/line/area/scatter/radar/pie/doughnut worksheet chart families as first-party vector drawing snapshots when chart data can be read, common number formats, basic explicit cell font emphasis/font color/fill color, two-color conditional color-scale fills, conditional data bars, conditional icon-set indicators, horizontal/vertical alignment, simple cell borders including dashed, dotted, dash-dot, double, and diagonal strokes, external cell hyperlinks, internal workbook links to exact exported cells as PDF named destinations with sheet-level fallback, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, fit-to-width table sizing, reusable PDF page size/margin overrides, sheet headings, header-row styling/repetition, row caps, empty-cell text, and `ExcelPdfSaveOptions.Warnings` diagnostics for initial unsupported/simplified workbook export cases. +- Continue with richer worksheet header/footer formatting beyond line-level styles, richer fit-to-height and automatic multi-page pagination/scaling, richer merged-cell edge cases, richer worksheet image placement fidelity beyond exported table-cell anchors, richer chart fidelity beyond initial column/bar/line/area/scatter/radar/pie/doughnut snapshots, and richer cell style fidelity such as additional conditional formats and locale-specific formats. +- Broaden unsupported-workbook diagnostics beyond the initial warning coverage for header/footer formatting, unsupported or unreadable images, unsupported or unreadable chart snapshots, and row truncation. #### PowerPoint To PDF @@ -539,7 +539,7 @@ Possible future splits: - `OfficeIMO.Pdf.VisualTests` for test-only harness helpers. - `OfficeIMO.Word.Pdf` as the Word exporter package. -- `OfficeIMO.Excel.Pdf` as the Excel exporter package. +- `OfficeIMO.Excel.Pdf` as the initial Excel exporter package, currently focused on worksheet table and image export with print area, orientation, margins, hidden workbook worksheet filtering, hidden rows and columns, repeated print-title rows, manual row and column page breaks, simple worksheet header/footer text zones with page/date/time/workbook tokens, supported images, and simple line-level font/size/color formatting, worksheet merged-cell spans, external cell hyperlinks, exact-cell internal workbook-link destinations with sheet-level fallback, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, fit-to-width table sizing, common number formats, basic explicit cell font/fill/conditional-color-scale/data-bar/alignment/border styling, and initial unsupported-feature export warnings. - `OfficeIMO.PowerPoint.Pdf` as the PowerPoint exporter package. Do not create package splits before the internal boundaries are useful. @@ -552,12 +552,13 @@ These are the eventual PSWriteOffice-facing operations needed to replace PSWrite - Add text. - Add table. - Add image. -- Add list. Initial bulleted and numbered list blocks exist in `OfficeIMO.Pdf`, including reusable `PdfListStyle` defaults and per-list overrides for Word-like typography, indentation, marker spacing, color, rhythm, keep-together, and keep-with-next page flow. +- Add list. Initial bulleted and numbered list blocks exist in `OfficeIMO.Pdf`, including reusable `PdfListStyle` defaults and per-list overrides for Word-like typography, indentation, marker spacing, color, rhythm, keep-together, keep-with-next page flow, and rich list-item runs with scoped color, bold/italic, underline/strike, font size, background/highlight, baseline, tabs, links, and per-item bookmark anchors in top-level and row-column flows. - Add page break. - Save PDF. - Read PDF text. - Get PDF metadata. - Get PDF page count and page sizes. +- Validate PDF. - Split PDF. - Merge PDF. - Extract PDF pages. @@ -566,18 +567,18 @@ These are the eventual PSWriteOffice-facing operations needed to replace PSWrite - Add watermark. - Add stamp. - Inspect PDF form fields. Initial simple AcroForm field inventory exists through `PdfInspector.Inspect(...)` / `Preflight(...).DocumentInfo.FormFields`. -- Create PDF text fields, check boxes, scalar choice fields, and multi-select choice fields. Initial generated text field, check box, and choice field creation exists through `PdfDoc.TextField(...)`, `PdfDoc.CheckBox(...)`, `PdfDoc.ChoiceField(...)`, and `PdfDoc.MultiSelectChoiceField(...)`, including compose item/element and row/column placement. +- Create PDF text fields, check boxes, scalar choice fields, multi-select choice fields, and radio button groups. Initial generated text field, check box, choice field, vertical radio group, and table-cell check box creation exists through `PdfDoc.TextField(...)`, `PdfDoc.CheckBox(...)`, `PdfDoc.ChoiceField(...)`, `PdfDoc.MultiSelectChoiceField(...)`, `PdfDoc.RadioButtonGroup(...)`, and `PdfTableCell.WithCheckBoxes(...)`, including `PdfFormFieldStyle` appearance controls plus compose item/element, row/column, and table-cell placement. - Fill PDF form. Initial simple AcroForm value fill exists through `PdfFormFiller.FillFields(...)` / `FillFieldsToBytes(...)`, including path-to-output-stream helpers. - Flatten PDF form. Initial simple text-widget, choice-widget, and button-widget flattening exists through `PdfFormFiller.FlattenFields(...)`, `FlattenFieldsToBytes(...)`, `FillAndFlattenFields(...)`, and `FillAndFlattenFieldsToBytes(...)`, including path-to-output-stream helpers. - Convert Word to PDF. -- Convert Excel to PDF. +- Convert Excel to PDF. Initial worksheet table and image export exists in `OfficeIMO.Excel.Pdf`, including print areas, orientation, margins, hidden workbook worksheet filtering, hidden rows and columns, repeated print-title rows, manual row and column page breaks, simple header/footer text zones with page/date/time/workbook tokens, supported images, and simple line-level font/size/color formatting, worksheet merged-cell spans, external cell hyperlinks, exact-cell internal workbook-link destinations with sheet-level fallback, supported worksheet drawing images, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, fit-to-width table sizing, common number formats, basic explicit cell font/fill/conditional-color-scale/data-bar/alignment/border styling, and initial unsupported-feature export warnings; a native Excel daily-workbook Poppler raster baseline now covers the main daily-use export path; richer print-fidelity features remain roadmap work. - Convert PowerPoint to PDF. ## Near-Term Issue Slices Good first issues should be small and visual: -1. Add visual regression harness and baselines for current `OfficeIMO.Pdf` examples. Initial geometry/content-stream baselines exist, and the first repo-local Poppler raster comparison lane now covers the professional report, a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, plus compact hello-world, core-layout, style-cheatsheet, styled-runs, tabs-leaders, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers scenarios with diff artifacts; next step is expanding raster coverage across the remaining runnable example set. +1. Add visual regression harness and baselines for current `OfficeIMO.Pdf` examples. Initial geometry/content-stream baselines exist, and the first repo-local Poppler raster comparison lane now covers the professional report, a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, the first native Word-to-first-party-PDF report fixture, a native Word daily-layout fixture, a native Word table-cell picture-control fixture, a native Excel daily-workbook fixture, plus compact hello-world, core-layout, style-cheatsheet, styled-runs, tabs-leaders, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers scenarios with diff artifacts; next step is expanding raster coverage across the remaining runnable example set. 2. Fix paragraph spacing so generated reports no longer look stretched. 3. Fix table cell overflow and add wrapping tests. 4. Update `OfficeIMO.Pdf/README.md` with real current features and roadmap link. @@ -589,7 +590,7 @@ Good first issues should be small and visual: 10. Implement delete and rotate page helpers. Initial API: `PdfPageEditor`. 11. Implement metadata editing helpers. Initial API: `PdfMetadataEditor`. 12. Implement generated navigation anchors, links, and outlines. Initial APIs: `PdfOptions.CreateOutlineFromHeadings` for heading outlines, generic `Bookmark(...)` flow anchors for named destinations, and paragraph `LinkToBookmark(...)` runs for internal document navigation. -13. Add professional report example that becomes the visual quality reference. Initial professional report baseline exists in `PdfDocVisualBaselineTests`, runnable example coverage exists in `OfficeIMO.Examples/Pdf/Pdf.ProfessionalReport.cs`, and Poppler-rendered PNG approval exists in `PdfDocRasterVisualBaselineTests` alongside a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, compact smoke, core-layout, style-cheatsheet, styled-runs, tabs-leaders, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers approvals. +13. Add professional report example that becomes the visual quality reference. Initial professional report baseline exists in `PdfDocVisualBaselineTests`, runnable example coverage exists in `OfficeIMO.Examples/Pdf/Pdf.ProfessionalReport.cs`, and Poppler-rendered PNG approval exists in `PdfDocRasterVisualBaselineTests` alongside a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, a native Word-to-first-party-PDF report fixture, a native Word daily-layout fixture, a native Word table-cell picture-control fixture, a native Excel daily-workbook fixture, compact smoke, core-layout, style-cheatsheet, styled-runs, tabs-leaders, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers approvals. ## Non-Goals For Now diff --git a/Docs/officeimo.pdf.support-matrix.md b/Docs/officeimo.pdf.support-matrix.md index 01233cefa..ca6eb8c61 100644 --- a/Docs/officeimo.pdf.support-matrix.md +++ b/Docs/officeimo.pdf.support-matrix.md @@ -13,25 +13,25 @@ Status values: | Area | Capability | Status | Current API / Notes | | --- | --- | --- | --- | -| Create | Build a PDF from fluent blocks | Partial | `PdfDoc.Create()`, headings with Word-like spacing-before suppression at fresh page/column starts, paragraphs, rich text, Word-compatible default half-inch paragraph tab stops with `PdfParagraphStyle.DefaultTabStopWidth` overrides, explicit paragraph tab runs with dotted, hyphen, or underscore leaders and left/center/right/decimal alignment through `PdfParagraphBuilder.Tab(PdfTabLeaderStyle.Dots, PdfTabAlignment.DecimalSeparator)`, Word-like flow-object spacing-before suppression at fresh page/column starts, invisible `Spacer(...)` flow gaps, bullets, panels, rows/columns, simple tables, JPEG/PNG images, headers, footers, page numbers; page-scoped content compose supports direct `Item(...)` groups, nested element groups, `Spacer(...)` rhythm blocks, and `PageBreak()` page transitions alongside columns and rows | +| Create | Build a PDF from fluent blocks | Partial | `PdfDoc.Create()`, headings with Word-like spacing-before suppression at fresh page/column starts, paragraphs, rich text with scoped per-run standard font family, font-size, and background/highlight changes, Word-compatible default half-inch paragraph tab stops with `PdfParagraphStyle.DefaultTabStopWidth` overrides, explicit paragraph tab runs with dotted, hyphen, or underscore leaders and left/center/right/decimal alignment through `PdfParagraphBuilder.Tab(PdfTabLeaderStyle.Dots, PdfTabAlignment.DecimalSeparator)`, Word-like flow-object spacing-before suppression at fresh page/column starts, invisible `Spacer(...)` flow gaps, simple bullets/numbering plus rich list item runs through `PdfListItem`, `RichBullets(...)`, and `RichNumbered(...)`, panels, rows/columns, simple tables, JPEG/PNG images, headers, footers, page numbers; page-scoped content compose supports direct `Item(...)` groups, nested element groups, `Spacer(...)` rhythm blocks, and `PageBreak()` page transitions alongside columns and rows | | Create | Save to bytes/path/stream workflow | Supported | `ToBytes`, `Save(string)`, `Save(Stream)`, `SaveAsync(string)`, and `SaveAsync(Stream)` | | Create | Metadata | Supported | `PdfDoc.Meta(title, author, subject, keywords)` | -| Create | Page setup | Partial | `PdfOptions.PageSize`, `PdfOptions.Margins`, `PageSize.FromInches(...)`, `PageSize.FromCentimeters(...)`, `PageMargins.UniformInches(...)`, `PageMargins.FromInches(...)`, `PageMargins.UniformCentimeters(...)`, `PageMargins.FromCentimeters(...)`, `PdfDoc.Size(...)`, `Margin(...)`, `Margin(PageMargins)`, `Orientation(...)`, `Portrait()`, `Landscape()`, top-level `PdfDoc.Page(...)` / `Section(...)`, `PdfDoc.Compose(...Page...)` / `Compose(...Section...)`, and matching `PdfPageCompose` methods provide Word-like size, orientation, margin, and scoped flow setup with immediate intrinsic scalar validation and reusable Word-compatible `PageMargins` presets; richer section inheritance and mid-page section breaks remain roadmap work | -| Create | Tables | Partial | Basic styling, proportional standard-font wrapping for cells and captions, report-friendly `TableStyles.Light()` defaults, Word-like table presets with neutral header/footer separators, including `TableNormal`, `TableGrid`, `TableGridLight`, `PlainTable1`, `GridTable1Light`, `ListTable1Light`, and Accent1-6 variants with Word default theme border, separator, and soft band colors for the existing light grid/list styles, canonical style normalization through `TableStyles.GetCanonicalWordStyleName(...)` / `TryGetCanonicalWordStyleName(...)`, canonical display names through `TableStyles.CanonicalWordStyleNames`, accepted aliases through `TableStyles.SupportedWordStyleNames`, row/header/footer separators, side-specific per-cell border overrides, body column fills, per-cell fills, horizontal/vertical cell alignment, configurable header/footer row counts with render-time bounds validation, minimum row height, table left indentation and max-width caps with left/center/right placement, spacing before/after with Word-like spacing-before suppression at fresh page/column starts, keep-together and keep-with-next page flow with matching first-row preflight diagnostics that honor configured column widths, fixed/min/max column widths, relative column width weights, column-scoped style bounds validation for sizing/fills/horizontal and vertical alignment, OfficeIMO.Drawing-backed auto-fit column sizing with token minimums, initial `PdfTableCell` column spans, row spans, rectangular merged cells with combined-box alignment, overlong row-span validation, row-spanned-cell header/footer boundary validation, row-spanned explicit cell fills/borders, explicit cell fill/border coordinate bounds validation plus row-span and column-span continuation-slot skips, row/header/footer separators, body-column background fills that skip merged-cell continuation columns, row/background fills, and default table border grids that skip row-spanned and rectangular merged-cell interiors, and cell-owned URI links including linked column/row-spanned cell annotations over the merged text frame in top-level and row/column flows, row-by-row pagination, oversized-row line splitting, repeated header rows, caption-plus-first-row overflow diagnostics, generic line-item visual rhythm gates, and PDF-level clipping when cell text would escape its cell rectangle with a small antialiasing tolerance exist; richer merged-cell conflict behavior and report tables are still roadmap work | -| Create | Rows and columns | Partial | `PdfRowCompose` supports percentage columns with explicit gutters plus reusable `PdfRowStyle` defaults/overrides for Word-like column gutters, row-level spacing, keep-together, and keep-with-next page flow through `PdfOptions.DefaultRowStyle`, `PdfDoc.DefaultRowStyle(...)`, `PdfPageCompose.DefaultRowStyle(...)`, `PdfTheme.RowStyle`, or per-row `Style(...)`; column flows can use `Item(...)` groups and `Spacer(...)` for invisible vertical rhythm without fake blank text; kept rows that exceed the available page content height fail with a clear diagnostic, and richer section/column balancing remains roadmap work | -| Create | Images | Partial | JPEG and simple non-interlaced 8-bit grayscale/grayscale-alpha/RGB/RGBA PNG placement, including PNG alpha soft masks; image payload validation uses `OfficeIMO.Drawing.OfficeImageReader` and rejects unsupported recognized formats clearly; flow images can use shared `OfficeImageFit` stretch/contain/cover placement, shared `OfficeClipPath` rectangle/rounded/freeform clipping, and optional URI link annotations with contents metadata in top-level and row/column flows | +| Create | Page setup | Partial | `PdfOptions.PageSize`, `PdfOptions.Margins`, `PdfOptions.BackgroundColor`, `PageSize.FromInches(...)`, `PageSize.FromCentimeters(...)`, `PageMargins.UniformInches(...)`, `PageMargins.FromInches(...)`, `PageMargins.UniformCentimeters(...)`, `PageMargins.FromCentimeters(...)`, `PdfDoc.Size(...)`, `Margin(...)`, `Margin(PageMargins)`, `Orientation(...)`, `Portrait()`, `Landscape()`, `Background(...)`, top-level `PdfDoc.Page(...)` / `Section(...)`, `PdfDoc.Compose(...Page...)` / `Compose(...Section...)`, and matching `PdfPageCompose` methods provide Word-like size, orientation, margin, page-background color, and scoped flow setup with immediate intrinsic scalar validation and reusable Word-compatible `PageMargins` presets; richer section inheritance, mid-page section breaks, and image/background-shape page fills remain roadmap work | +| Create | Tables | Partial | Basic styling, proportional standard-font wrapping for cells and captions, rich `PdfTableCell` text runs with scoped color, bold/italic, underline/strike, font size, background/highlight, baseline, tabs, and links rendered through the shared rich text engine, table-cell images through `PdfTableCell.WithImages(...)`, report-friendly `TableStyles.Light()` defaults, Word-like table presets with neutral header/footer separators, including `TableNormal`, `TableGrid`, `TableGridLight`, `PlainTable1`, `GridTable1Light`, and Accent1-6 variants with Word default theme border, separator, and soft band colors for the existing light grid/list styles, canonical style normalization through `TableStyles.GetCanonicalWordStyleName(...)` / `TryGetCanonicalWordStyleName(...)`, canonical display names through `TableStyles.CanonicalWordStyleNames`, accepted aliases through `TableStyles.SupportedWordStyleNames`, row/header/footer separators, side-specific per-cell border overrides with independent side colors, widths, solid/dashed/dotted/dash-dot strokes, two-line borders, and diagonal-up/diagonal-down cell lines, body column fills, per-cell fills, proportional per-cell data bars through `PdfCellDataBar`, per-cell vector icons through `PdfCellIcon`, per-cell padding overrides, column and per-cell horizontal/vertical cell alignment, configurable cell spacing, configurable visual header/footer row counts with render-time bounds validation, optional repeated-header row count through `PdfTableStyle.RepeatHeaderRowCount`, table-wide and per-row minimum heights, table-wide and per-row row-break policies, table left indentation and max-width caps with left/center/right placement, spacing before/after with Word-like spacing-before suppression at fresh page/column starts, keep-together and keep-with-next page flow with matching first-row preflight diagnostics that honor configured column widths, fixed/min/max column widths including proportional fitting for oversized fixed-width tables in top-level and row/column frames, relative column width weights, column-scoped style bounds validation for sizing/fills/horizontal and vertical alignment, OfficeIMO.Drawing-backed auto-fit column sizing with token minimums, initial `PdfTableCell` column spans, row spans, rectangular merged cells with combined-box alignment, overlong row-span validation, row-spanned-cell header/footer boundary validation, row-spanned explicit cell fills/borders, explicit cell fill/data-bar/icon/border/padding/alignment coordinate bounds validation plus row-span and column-span continuation-slot skips, row/header/footer separators, body-column background fills that skip merged-cell continuation columns, row/background fills, and default table border grids that skip row-spanned and rectangular merged-cell interiors, cell-owned URI or named-destination links including linked column/row-spanned cell annotations over the merged text frame in top-level and row/column flows, and cell-owned named-destination anchors through `PdfTableCell.NamedDestinationName` / `WithNamedDestination(...)`, row-by-row pagination, oversized-row line splitting, repeated header rows, caption-plus-first-row overflow diagnostics, generic line-item visual rhythm gates, and PDF-level clipping when cell text would escape its cell rectangle with a small antialiasing tolerance exist; richer merged-cell conflict behavior and report tables are still roadmap work | +| Create | Rows and columns | Partial | `PdfRowCompose` supports percentage columns with explicit gutters plus reusable `PdfRowStyle` defaults/overrides for Word-like column gutters, optional vertical column separators, row-level spacing, keep-together, and keep-with-next page flow through `PdfOptions.DefaultRowStyle`, `PdfDoc.DefaultRowStyle(...)`, `PdfPageCompose.DefaultRowStyle(...)`, `PdfTheme.RowStyle`, or per-row `Style(...)` / `ColumnSeparator(...)`; column flows can use `Item(...)` groups and `Spacer(...)` for invisible vertical rhythm without fake blank text; the native Word exporter maps Word section columns with explicit column breaks, inline paragraph column breaks, explicit unequal section column widths from Word section properties, Word section column separator lines, and a heading/keep-with-next-aware automatic distribution fallback for multi-column sections without explicit breaks through this same row/column flow; kept rows that exceed the available page content height fail with a clear diagnostic, and richer balanced newspaper-style section flow remains roadmap work | +| Create | Images | Partial | JPEG and simple non-interlaced 8-bit grayscale/grayscale-alpha/RGB/RGBA PNG placement, including PNG alpha soft masks; image payload validation uses `OfficeIMO.Drawing.OfficeImageReader` and rejects unsupported recognized formats clearly; flow images can use shared `OfficeImageFit` stretch/contain/cover placement, shared `OfficeClipPath` rectangle/rounded/freeform clipping, and optional URI link annotations with contents metadata in top-level, row/column, and table-cell flows | | Create | Drawing primitives | Partial | Flow lines, rectangles, rounded rectangles, ellipses, polygons, paths, and simple grouped drawing scenes render from shared `OfficeIMO.Drawing` descriptors with solid fill, two-stop linear gradient fill, simple offset shadow, stroke/width/dash style/line cap/line join/fill and stroke opacity/affine transform/clipping path/alignment/spacing/keep-with-next flow plus optional URI link annotations with contents metadata on generic shape and drawing scene blocks and on vector convenience helpers; richer gradients and richer shape effects remain roadmap work | -| Create | Headers and footers | Partial | Simple generated page headers and footers support `PdfOptions`, document-level `PdfDoc.Header(...)` / `PdfDoc.Footer(...)`, and page-scoped `PdfPageCompose.Header(...)` / `Footer(...)` configuration with literal text formats, `{page}` / `{pages}` tokens, composed text/token segment builders, Word-like left/center/right text zones through `PdfHeaderCompose.Zones(...)` and `PdfFooterCompose.Zones(...)`, plus first-page/even-page zone variants through `FirstPageZones(...)` and `EvenPagesZones(...)`, font, size, text color, alignment, margin-relative offsets with placement validation, first-page overrides, and odd/even page overrides; zone text is measured and rejected when it would overflow or overlap. First/even/odd selection is scoped to the current document or section flow, while visible page tokens continue by default across flows for Word-like section numbering. `PdfOptions.PageNumberStart`, `PdfDoc.PageNumberStart(...)`, and `PdfPageCompose.PageNumberStart(...)` can explicitly restart the visible page number without breaking first/even/odd variant selection, and `PdfOptions.PageNumberStyle`, `PdfDoc.PageNumberStyle(...)`, and `PdfPageCompose.PageNumberStyle(...)` can render decimal, roman, or alphabetic page tokens. Richer image/table and Office document mapped headers/footers remain roadmap work | +| Create | Headers and footers | Partial | Simple generated page headers and footers support `PdfOptions`, document-level `PdfDoc.Header(...)` / `PdfDoc.Footer(...)`, and page-scoped `PdfPageCompose.Header(...)` / `Footer(...)` configuration with literal text formats, `{page}` / `{pages}` tokens, composed text/token segment builders, Word-like left/center/right text zones through `PdfHeaderCompose.Zones(...)` and `PdfFooterCompose.Zones(...)`, simple images through `Image(...)`, simple shared drawing shapes through `Shape(...)`, plus first-page/even-page text, image, and shape variants through `FirstPageZones(...)`, `EvenPagesZones(...)`, `FirstPageImage(...)`, `EvenPagesImage(...)`, `FirstPageShape(...)`, and `EvenPagesShape(...)`, font, size, text color, alignment, margin-relative offsets with placement validation, first-page overrides, and odd/even page overrides; zone text is measured and rejected when it would overflow or overlap. First/even/odd selection is scoped to the current document or section flow, while visible page tokens continue by default across flows for Word-like section numbering. `PdfOptions.PageNumberStart`, `PdfDoc.PageNumberStart(...)`, and `PdfPageCompose.PageNumberStart(...)` can explicitly restart the visible page number without breaking first/even/odd variant selection, and `PdfOptions.PageNumberStyle`, `PdfDoc.PageNumberStyle(...)`, and `PdfPageCompose.PageNumberStyle(...)` can render decimal, roman, or alphabetic page tokens. The native Word-to-PDF path maps simple default/first/even header and footer text/images/shapes into this model, including left/center/right paragraph alignment, simple text-box text routed through header/footer zones, and simple two-/three-cell header/footer table text/images/shapes through first-party zones; richer table header and footer fidelity remains roadmap work | | Create | Themes and styles | Partial | `PdfTheme` bundles default text, paragraph, heading, list, table, panel, rule, image, drawing, and row styles for `PdfOptions.ApplyTheme(...)`, `PdfDoc.Theme(...)`, and `PdfPageCompose.Theme(...)`; `PdfTheme.WordLike()` provides a generic opt-in document theme with neutral typography, readable paragraph/list/table rhythm, heading hierarchy, table footer separators for summary rows, and flow-object spacing without introducing invoice/report-specific engine concepts | -| Create | Fonts | Partial | Standard PDF fonts only; Helvetica and Times family measurement uses built-in glyph-width tables, including common WinAnsi punctuation and accented Latin letters, for generated layout and standard-font readback; TrueType/OpenType embedding is planned | -| Create | Outlines/bookmarks | Partial | `PdfOptions.CreateOutlineFromHeadings` writes nested PDF outlines from H1/H2/H3 blocks, generic `PdfDoc.Bookmark(...)` / compose `Bookmark(...)` helpers write simple PDF named destinations from the current top-level or row/column flow position with duplicate-name validation, and paragraph `LinkToBookmark(...)` runs write internal GoTo annotations targeting those named destinations with missing-target validation | -| Create | Forms | Partial | `PdfDoc.TextField(...)`, `PdfDoc.CheckBox(...)`, `PdfDoc.ChoiceField(...)`, and `PdfDoc.MultiSelectChoiceField(...)` write initial simple AcroForm text fields, check boxes, scalar choice fields, and multi-select choice fields in top-level page flow, compose item/element flow, and row/column flow with visible normal appearance streams, `/Widget` annotations, catalog `/AcroForm`, `/NeedAppearances`, Helvetica default resource registration, check box Off/selected appearance states, and choice field flags/options; generated fields are immediately readable by `PdfInspector` / `PdfLogicalDocument` and can be filled or filled-and-flattened by `PdfFormFiller`. Radio buttons, rich field appearance styling, and Word/Excel/PowerPoint mapped form export remain roadmap work | +| Create | Fonts | Partial | Standard PDF fonts only; document defaults, header/footer fonts, default text styles, and rich text runs can select Helvetica, Times, or Courier family variants without embedding. Helvetica and Times family measurement uses built-in glyph-width tables, including common WinAnsi punctuation and accented Latin letters, for generated layout and standard-font readback; TrueType/OpenType embedding is planned | +| Create | Outlines/bookmarks | Partial | `PdfOptions.CreateOutlineFromHeadings` writes nested PDF outlines from H1/H2/H3 blocks, generic `PdfDoc.Bookmark(...)` / compose `Bookmark(...)` helpers write simple PDF named destinations from the current top-level or row/column flow position with duplicate-name validation, rich `PdfListItem` can anchor per-item named destinations in top-level and row/column list flows, and paragraph `LinkToBookmark(...)` runs plus bookmark-targeted H1/H2/H3 links write internal GoTo annotations targeting those named destinations with missing-target validation | +| Create | Forms | Partial | `PdfDoc.TextField(...)`, `PdfDoc.CheckBox(...)`, `PdfDoc.ChoiceField(...)`, `PdfDoc.MultiSelectChoiceField(...)`, and `PdfDoc.RadioButtonGroup(...)` write initial simple AcroForm text fields, check boxes, scalar choice fields, multi-select choice fields, and vertical radio button groups in top-level page flow, compose item/element flow, and row/column flow; `PdfTableCell.WithCheckBoxes(...)` writes simple check boxes inside table cells and `PdfTableCell.WithFormFields(...)` writes simple table-cell text and scalar choice fields. Generated fields include visible normal appearance streams, `/Widget` annotations, catalog `/AcroForm`, generated `/NeedAppearances false`, Helvetica default resource registration, button Off/selected appearance states, choice field flags/options, and radio parent/kid widgets; `PdfFormFieldStyle` can set generated background, border, text, and button mark colors plus border width; generated fields are immediately readable by `PdfInspector` / `PdfLogicalDocument` and can be filled or filled-and-flattened by `PdfFormFiller`. The native Word exporter maps simple body-level and table-cell dropdown, combo box, and date picker content controls to these first-party form primitives. Richer field widgets and broader Word/Excel/PowerPoint mapped form export remain roadmap work | | Shared drawing | Color interop | Supported | `PdfColor.FromOfficeColor`, `PdfColor.FromOfficeColorOrNull`, `PdfColor.ToOfficeColor`, and implicit `OfficeColor` to `PdfColor` conversion | | Shared drawing | Image metadata | Supported | PDF image validation/rendering stores `OfficeImageInfo` on internal image blocks and uses `OfficeImageReader` for format detection | | Shared drawing | Font metadata, text measurement, image fitting, vector descriptors | Partial | Generated table auto-fit sizing uses `OfficeIMO.Drawing.OfficeTextMeasurer`; flow images use `OfficeIMO.Drawing.OfficeImageFit`; flow lines, rectangles, rounded rectangles, ellipses, polygons, paths, and grouped scenes use `OfficeIMO.Drawing.OfficeShape` / `OfficeDrawing`, including shared stroke dash/cap/join, two-stop linear gradient fill, simple offset shadow, fill/stroke opacity, affine transform, and clipping path descriptors; keep expanding this shared layer instead of duplicating reusable primitives inside `OfficeIMO.Pdf` | | Read | Load PDF object model | Partial | `PdfReadDocument.Load(byte[]/path/stream)` handles the current pragmatic parser scope and prefers the trailer `/Root` catalog when stale catalog objects remain in the file | | Read | Lightweight document probe | Supported | `PdfInspector.Probe(byte[]/path/stream)` returns `PdfDocumentProbe.HeaderVersion`, `HasEncryption`, `HasSignatures`, `HasForms`, `HasAnnotations`, `HasOutlines`, `HasCatalogViewSettings`, `HasPageLabels`, `HasCatalogNameTrees`, `HasNamedDestinations`, `HasOpenActions`, `HasViewerPreferences`, `HasTaggedContent`, `HasXmpMetadata`, `HasCatalogUri`, `HasOutputIntents`, `HasEmbeddedFiles`, `HasOptionalContent`, and `HasActiveContent` without full parsing so wrappers can choose safe read/manipulation paths | -| Read | Wrapper preflight | Supported | `PdfInspector.Preflight(byte[]/path/stream)` returns `CanRead`, `CanExtractText`, `CanExtractImages`, `CanReadLogicalObjects`, `CanRewrite`, `CanManipulatePages`, `CanFillSimpleFormFields`, `CanFlattenSimpleFormFields`, `CanFillAndFlattenSimpleFormFields`, `Can(PdfPreflightCapability)`, `GetCapabilityDiagnostics(PdfPreflightCapability)`, parsed `DocumentInfo` when available, structured `ReadBlockers` / `RewriteBlockers`, `HasReadBlocker(...)` / `HasRewriteBlocker(...)` helpers, and diagnostics for encrypted, signed, form-bearing, complex-outline-bearing, complex-page-label-bearing, unsupported-catalog-name-tree-bearing, complex-named-destination-name-tree-bearing, complex-open-action-dictionary-bearing, complex-viewer-preference-bearing, complex-XMP-metadata-bearing, complex-catalog-URI-bearing, tagged, complex-output-intent-bearing, complex-embedded-file-bearing, complex-optional-content-bearing, active-content-bearing, invalid rewrite object references, unsupported page content stream filters, invalid, empty, or parser-unsupported inputs; simple direct catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array open actions, simple GoTo open-action dictionaries, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, and simple optional-content metadata are detected but no longer block rewrite; image extraction can still be allowed when document inspection succeeds but content-stream filters block text or logical-object extraction, and simple AcroForm fill/flatten gates are reported separately because form PDFs still block generic page-rewrite helpers while dedicated form helpers can support a narrower safe path | +| Read | Wrapper validation/preflight | Supported | `PdfValidator.Validate(byte[]/path/stream)` and `PdfInspector.Preflight(byte[]/path/stream)` return `IsValid`/`CanRead`, `CanExtractText`, `CanExtractImages`, `CanReadLogicalObjects`, `CanRewrite`, `CanManipulatePages`, `CanFillSimpleFormFields`, `CanFlattenSimpleFormFields`, `CanFillAndFlattenSimpleFormFields`, `Can(PdfPreflightCapability)`, `GetCapabilityDiagnostics(PdfPreflightCapability)`, parsed `DocumentInfo` when available, structured `ReadBlockers` / `RewriteBlockers`, `HasReadBlocker(...)` / `HasRewriteBlocker(...)` helpers, and diagnostics for encrypted, signed, form-bearing, complex-outline-bearing, complex-page-label-bearing, unsupported-catalog-name-tree-bearing, complex-named-destination-name-tree-bearing, complex-open-action-dictionary-bearing, complex-viewer-preference-bearing, complex-XMP-metadata-bearing, complex-catalog-URI-bearing, tagged, complex-output-intent-bearing, complex-embedded-file-bearing, complex-optional-content-bearing, active-content-bearing, invalid rewrite object references, unsupported page content stream filters, invalid, empty, or parser-unsupported inputs; simple direct catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array open actions, simple GoTo open-action dictionaries, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, and simple optional-content metadata are detected but no longer block rewrite; image extraction can still be allowed when document inspection succeeds but content-stream filters block text or logical-object extraction, and simple AcroForm fill/flatten gates are reported separately because form PDFs still block generic page-rewrite helpers while dedicated form helpers can support a narrower safe path | | Read | Page count, page sizes, and rotation | Supported | `PdfInspector.Inspect(byte[]/path/stream)` and `InspectPageRanges(byte[]/path/stream, PdfPageRange...)` return `PdfDocumentInfo.PageCount`, `HeaderVersion`, `PdfPageInfo` geometry, `RotationDegrees`, page-level link annotations, page-level AcroForm widget annotations when readable, signature marker state, form marker state, annotation marker state, outline marker state, catalog-view-setting marker state, page-label marker state, catalog-name-tree marker state, named-destination marker state, open-action marker state, viewer-preference marker state, tagged-structure marker state, XMP metadata marker state, catalog URI marker state, output-intent marker state, embedded-file marker state, optional-content marker state, and active-content marker state; page-range inspection preserves caller order and overlaps while narrowing page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages | | Read | Catalog view and identity | Partial | `PdfReadDocument` and `PdfInspector.Inspect(...)` expose simple catalog `CatalogPageMode`, `CatalogPageLayout`, `CatalogVersion`, and `CatalogLanguage` | | Read | Page labels | Partial | `PdfReadDocument.PageLabels` and `PdfInspector.Inspect(...).PageLabels` read simple direct catalog `/PageLabels` number trees as generic page-label rules with `StartPageIndex`, `StartPageNumber`, `Style`, `Prefix`, `StartNumber`, `PageLabelCount`, and `HasReadablePageLabels`; rewrite-style copied-page label reindexing follows the trailer-root page tree so stale catalog objects do not skew selected-page labels; complex page-label trees remain marker/blocker-only until richer number-tree support exists | @@ -43,7 +43,7 @@ Status values: | Read | Text extraction | Partial | `PdfReadDocument.ExtractText`, `PdfReadPage.ExtractText`, `PdfTextExtractor.ExtractAllText(byte[]/path/stream)`, `PdfTextExtractor.ExtractAllTextByPageRanges(byte[]/path/stream, PdfPageRange...)`, `PdfTextExtractor.ExtractTextByPage(byte[]/path/stream)`, and `PdfTextExtractor.ExtractTextByPageRanges(byte[]/path/stream, PdfPageRange...)`; byte/path/stream whole-document extraction can write UTF-8 text to output paths or caller-owned streams, selected range extraction can return one concatenated text result or write one text file/stream for wrapper-style `Convert-PdfToText -Pages`, byte-array/path/stream page extraction can write deterministic `source-page-0001.txt` files, and range-list text extraction preserves caller order plus repeated or overlapping selections while writing selected source-page-numbered files with or without layout options | | Read | Text positions/spans | Partial | `PdfReadPage.GetTextSpans()` returns generated standard-font spans with glyph-width-based advances when `/Widths` is omitted, including common WinAnsi punctuation and accented Latin letters | | Read | Image extraction | Partial | `PdfImageExtractor.ExtractImages(byte[]/path/stream/document)`, `ExtractImagesByPageRanges(byte[]/path/stream/document, PdfPageRange...)`, and `PdfReadDocument.ExtractImages()` return page image XObjects; byte-array, path, and stream extraction can also write deterministic `source-page-0001-image-0001.png` files for all pages or selected source-page ranges, while range-list image extraction preserves caller order and deduplicates overlapping selections; JPEG images are returned as JPEG files and simple PNG-predictor Flate images as PNG files, including compatible grayscale/RGB Flate images with grayscale `/SMask` alpha as gray-alpha/RGBA PNGs | -| Read | Logical object model | Partial | `PdfLogicalDocument.Load(byte[]/path/stream, PdfTextLayoutOptions?)`, `LoadPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `From(PdfReadDocument, ...)`, and `FromPageRanges(PdfReadDocument, options, PdfPageRange...)` expose one wrapper-friendly read surface with metadata, selected source pages in caller order when ranges are used, `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)` helpers that preserve range-selection duplicates, document-level `TextBlocks`, `Headings`, `Paragraphs`, `ListItems`, `Tables`, and `Images`, flattened logical `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on both documents and pages, line-level text blocks, heuristic headings, list item objects with marker/level/text hints, heuristic paragraph groups, leader rows, detected tables with row/column/cell objects, image XObjects, URI/named-destination link annotation objects with document-level `Links`, `LinksByUri`, `LinksByDestinationName`, `GetLinksByUri(...)`, and `GetLinksByDestinationName(...)`, page-level AcroForm widget objects with current `/AS` and named `/AP /N` normal appearance states, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags`, named signature flag helpers, and `/DA` metadata, simple AcroForm fields with typed `PdfFormFieldKind`, inherited common `/Ff` flag helpers, scalar and array current/default values, selected/default-selected choice-option matching, inherited text `/MaxLen`, inherited AcroForm/field-tree `/DA` default appearance strings, inherited `/Q` text alignment, inherited simple choice `/Opt` options, distinct field page-number helpers, field-local widget page lookups, named, kind-based, and page-number form-field lookup helpers, document-level `FormWidgets`, `FormWidgetsByFieldName`, `FormWidgetsByPageNumber`, `GetFormWidgets(string)`, `GetFormWidgets(int)`, and simple form-widget page/rectangle objects. `PdfLogicalDocument.ToMarkdown(...)` and `PdfLogicalPage.ToMarkdown(...)` render the same logical model as Markdown with headings, paragraphs, lists, detected tables, image placeholders, and optional link/form annotations. Range-based logical loads filter page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages while preserving duplicate selected page widgets in caller order. The two-page line-item statement fixture now guards source-page ordering, table readback, totals readback, and selected range ordering through the logical model. This is the first AST-style surface for PSWriteOffice-style workflows, but heading/paragraph/table/list detection remains heuristic rather than a full tagged-PDF or Word-like semantic reconstruction | +| Read | Logical object model | Partial | `PdfLogicalDocument.Load(byte[]/path/stream, PdfTextLayoutOptions?)`, `LoadPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `From(PdfReadDocument, ...)`, and `FromPageRanges(PdfReadDocument, options, PdfPageRange...)` expose one wrapper-friendly read surface with metadata, selected source pages in caller order when ranges are used, `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)` helpers that preserve range-selection duplicates, document-level `TextBlocks`, `Headings`, `Paragraphs`, `ListItems`, `Tables`, and `Images`, flattened logical `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on both documents and pages, line-level text blocks, heuristic headings, list item objects with marker/level/text hints, heuristic paragraph groups, leader rows, detected tables with row/column/cell objects, image XObjects, URI/named-destination link annotation objects with document-level `Links`, `LinksByUri`, `LinksByDestinationName`, `GetLinksByUri(...)`, and `GetLinksByDestinationName(...)`, page-level AcroForm widget objects with current `/AS` and named `/AP /N` normal appearance states, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags`, named signature flag helpers, and `/DA` metadata, simple AcroForm fields with typed `PdfFormFieldKind`, inherited common `/Ff` flag helpers, scalar and array current/default values, selected/default-selected choice-option matching, inherited text `/MaxLen`, inherited AcroForm/field-tree `/DA` default appearance strings, inherited `/Q` text alignment, inherited simple choice `/Opt` options, distinct field page-number helpers, field-local widget page lookups, named, kind-based, and page-number form-field lookup helpers, document-level `FormWidgets`, `FormWidgetsByFieldName`, `FormWidgetsByPageNumber`, `GetFormWidgets(string)`, `GetFormWidgets(int)`, and simple form-widget page/rectangle objects. `PdfLogicalDocument.ToMarkdown(...)`, `PdfLogicalPage.ToMarkdown(...)`, and `PdfTextExtractor.ExtractMarkdown(...)` / `ExtractMarkdownByPage(...)` / `ExtractMarkdownByPageRanges(...)` / `ExtractMarkdownByPageRangesAsDocument(...)` render the same logical model as Markdown with headings, paragraphs, lists, detected tables, image placeholders, optional link/form annotations, UTF-8 output-path/stream helpers, and deterministic per-page `.md` files for wrapper pipelines. Range-based logical loads filter page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages while preserving duplicate selected page widgets in caller order. The two-page line-item statement fixture now guards source-page ordering, table readback, totals readback, and selected range ordering through the logical model. This is the first AST-style surface for PSWriteOffice-style workflows, but heading/paragraph/table/list detection remains heuristic rather than a full tagged-PDF or Word-like semantic reconstruction | | Read | Simple structure extraction | Partial | `PdfReadPage.ExtractStructured(...)`, `PdfReadDocument.ExtractStructuredPages(...)`, `PdfReadDocument.ExtractHeadingsByPage(...)`, `PdfReadDocument.ExtractListItemsByPage(...)`, `PdfReadDocument.ExtractParagraphsByPage(...)`, `PdfTextExtractor.ExtractStructuredByPage(byte[]/path/stream, options)`, `ExtractStructuredByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractHeadingsByPage(byte[]/path/stream, options)`, `ExtractHeadingsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractListItemsByPage(byte[]/path/stream, options)`, `ExtractListItemsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `ExtractParagraphsByPage(byte[]/path/stream, options)`, `ExtractParagraphsByPageRanges(byte[]/path/stream, options, PdfPageRange...)`, `PdfTextExtractor.ExtractTablesByPage(byte[]/path/stream, options)`, and `ExtractTablesByPageRanges(byte[]/path/stream, options, PdfPageRange...)` expose column-aware text, heuristic headings, heuristic paragraph groups, list item marker/level hints, dot/hyphen/underscore leader rows that preserve decimal/currency value punctuation, and heuristic table rows/geometry for wrapper-friendly readback while preserving selected source page numbers for heading/list-item/paragraph/table results; `PdfTextExtractor.ExtractTablesByPage(pdfBytes, outputDirectory, baseName, options)`, `ExtractTablesByPage(inputPath, outputDirectory, options)`, `ExtractTablesByPage(stream, outputDirectory, baseName, options)`, and matching `ExtractTablesByPageRanges(...)` overloads write deterministic escaped CSV files per detected table for all pages or selected source-page ranges, including the two-page line-item statement fixture with selected source-page order, line-item rows, and totals guarded for wrapper use | | Manipulate | Split by page range | Partial | `PdfPageExtractor.ExtractPageRange(byte[]/path/stream, firstPage, lastPage)`, `ExtractPageRange(..., PdfPageRange)`, `ExtractPageRanges(..., PdfPageRange...)`, `SplitPages(byte[]/path/stream)`, and `SplitPageRanges(..., PdfPageRange...)` return bytes for wrapper pipelines; `PdfPageRange.Parse(...)`, `TryParse(...)`, `ParseMany("1-3,5")`, and `TryParseMany(...)` parse one-based single pages plus inclusive `first-last` / `first..last` range lists while preserving caller order; path and stream split helpers can also write deterministic `source-page-0001.pdf` and `source-pages-0001-0003.pdf` files; simple direct catalog `/PageMode`, `/PageLayout`, `/Version`, `/Lang`, simple direct `/PageLabels` number trees, simple outlines including simple GoTo action outline entries whose destinations point only at copied pages, direct `/Dests` dictionaries, simple `/Names` `/Dests` name trees including leaf `/Kids`, destination-array `/OpenAction` entries, simple GoTo open-action dictionaries, simple `/ViewerPreferences` dictionaries, simple catalog `/Metadata` XMP XML streams, simple catalog `/URI` base dictionaries, simple `/OutputIntents` metadata graphs, simple `/Names` `/EmbeddedFiles` attachment trees, simple catalog `/AF` associated-file arrays, and simple `/OCProperties` optional-content metadata are preserved, with copied-page labels reindexed, stale destinations/open actions pruned, stale outline trees/name-tree destinations dropped, and stale named-destination link annotations removed when their target pages are not copied; the two-page line-item statement fixture now guards split/extract readback through the logical model; currently scoped to PDFs handled by the OfficeIMO parser | | Manipulate | Merge PDFs | Partial | `PdfMerger.Merge(byte[]/stream inputs)` and `PdfMerger.MergeFilesToBytes(path inputs)` can return bytes or write to output streams, while `PdfMerger.MergeFiles(...)` writes merged files from `params` paths or enumerable path lists and can write enumerable file-list inputs to output streams for wrapper pipelines; simple direct catalog `/PageMode`, `/PageLayout`, `/Version`, `/Lang`, simple direct `/PageLabels` number trees, simple outline trees including simple GoTo action outline entries, direct `/Dests` dictionaries, simple `/Names` `/Dests` name trees, destination-array `/OpenAction` entries, simple GoTo open-action dictionaries, simple `/ViewerPreferences` dictionaries, simple catalog `/Metadata` XMP XML streams, simple catalog `/URI` base dictionaries, simple `/OutputIntents` metadata graphs, simple `/Names` `/EmbeddedFiles` attachment trees, simple catalog `/AF` associated-file arrays, and simple `/OCProperties` optional-content metadata are preserved from the first source; the two-page line-item statement fixture now guards merge-after-split readback through the logical model; currently scoped to parser-supported PDFs | @@ -57,12 +57,14 @@ Status values: | Manipulate | Update metadata | Partial | `PdfMetadataEditor.UpdateMetadata(byte[]/stream/path, ...)` and `UpdateMetadataToBytes(path, ...)` preserve unspecified fields, while `ReplaceMetadata(byte[]/stream/path, ...)`, `ReplaceMetadataToBytes(path, ...)`, and path output helpers replace the Info dictionary fields; helpers can write byte, stream, or path inputs to caller-owned output streams, and path helpers can also return bytes | | Manipulate | Text/image stamp/watermark | Partial | `PdfStamper.StampText(byte[]/stream/path, ...)`, `StampTextToBytes(path, ...)`, `WatermarkText(byte[]/stream/path, ...)`, `WatermarkTextToBytes(path, ...)`, `StampImage(byte[]/stream/path PDF, byte[]/stream image, ...)`, `StampImageToBytes(path PDF, byte[]/stream image, ...)`, `WatermarkImage(byte[]/stream/path PDF, byte[]/stream image, ...)`, and `WatermarkImageToBytes(path PDF, byte[]/stream image, ...)` append content streams to selected pages, return bytes for wrapper pipelines, and can write byte, stream, or path PDF inputs to paths or caller-owned output streams; `PdfTextStampOptions.UsePageRange(...)` / `UsePageRanges(...)` and `PdfImageStampOptions.UsePageRange(...)` / `UsePageRanges(...)` select inclusive one-based page ranges or parsed range lists from `firstPage` / `lastPage` pairs or `PdfPageRange` without wrappers materializing page arrays, with overlapping range-list selections treated as one page selection set; simple PNG alpha soft masks are preserved for image stamps/watermarks | | Forms | Inspect fields | Partial | `PdfInspector.Inspect(...)` and `Preflight(...).DocumentInfo` can list simple AcroForm fields through `PdfDocumentInfo.FormFields`, including document-level `/NeedAppearances`, `/SigFlags`, named `/SigFlags` helpers for signatures-exist and append-only, and `/DA`, fully qualified names, raw field types, typed `PdfFormFieldKind`, simple display `Value`, scalar or array `Values`, simple default display `DefaultValue`, scalar or array `DefaultValues`, selected/default-selected choice-option matching, alternate/mapping names, inherited common `/Ff` flag helpers such as read-only/required/no-export/text/button/choice/signature/button-kind/choice-kind hints, inherited text `/MaxLen`, inherited AcroForm/field-tree `/DA` default appearance strings, inherited `/Q` text alignment, inherited simple choice `/Opt` options with export/display text, distinct widget page numbers per field, field-local `WidgetsByPageNumber` and `GetWidgets(int)` helpers, and simple widget annotation field-name/page/rectangle/current-appearance/normal-appearance-state metadata plus named annotation `/F` flag helpers when readable; `PdfDocumentInfo` and `PdfLogicalDocument` expose `FormFieldsByName`, `FormFieldsByKind`, `FormFieldsByPageNumber`, `FormFieldNames`, `TryGetFormField(...)`, `GetFormFields(PdfFormFieldKind)`, and `GetFormFields(int)` so wrappers can query the same simple fields without hand-scanning raw lists, plus document-level and page-level `FormWidgets`, `FormWidgetsByFieldName`, `FormWidgetsByPageNumber`, `GetFormWidgets(string)`, and `GetFormWidgets(int)` lookup helpers for widget geometry and appearance state; rewrite-style page manipulation remains blocked for form PDFs until broader preservation exists | -| Forms | Fill fields | Partial | `PdfFormFiller.FillFields(...)` can update simple AcroForm text/choice-style string values and button name values by fully qualified field name from bytes, paths, or streams, accepts choice values as export values or `/Opt` display text when available while storing the export value and painting display text, supports multi-select choice arrays through `PdfFormFieldValue.FromValues(...)`, generates simple text-widget normal appearance streams and simple button-widget Off/selected appearance states for widgets with `/Rect`, marks `/NeedAppearances true`, returns bytes from path inputs, writes path inputs to paths or caller-owned output streams, and rejects signed or active-content PDFs; rich widgets, JavaScript actions, and full appearance regeneration remain roadmap work | +| Forms | Fill fields | Partial | `PdfFormFiller.FillFields(...)` can update simple AcroForm text/choice-style string values and button name values by fully qualified field name from bytes, paths, or streams, accepts choice values as export values or `/Opt` display text when available while storing the export value and painting display text, supports multi-select choice arrays through `PdfFormFieldValue.FromValues(...)`, updates radio button groups by switching only the matching child widget appearance state on, generates simple text-widget normal appearance streams and simple button-widget Off/selected appearance states for widgets with `/Rect`, marks `/NeedAppearances true`, returns bytes from path inputs, writes path inputs to paths or caller-owned output streams, and rejects signed or active-content PDFs; rich widgets, JavaScript actions, and full appearance regeneration remain roadmap work | | Forms | Flatten forms | Partial | `PdfFormFiller.FlattenFields(...)` and `FillAndFlattenFields(...)` can paint simple text-widget appearances, simple choice-widget text appearances with `/Opt` display text when available for scalar or array selected values, and simple button-widget normal appearance states into page content, generating minimal button appearances when needed, remove those page annotations, and remove the AcroForm tree for parser-supported PDFs from bytes, paths, or streams; helpers return bytes from path inputs and write path inputs to paths or caller-owned output streams; rich/custom appearances, JavaScript actions, and safe complex form preservation remain roadmap work | | Security | Encryption/signatures/redaction | Partial | `PdfInspector.Probe` reports encryption/signature/form/outline/catalog-view-setting/page-label/catalog-name-tree/named-destination/open-action/viewer-preference/tagged-structure/XMP-metadata/catalog-URI/output-intent/embedded-file/optional-content/active-content markers and `PdfInspector.Preflight` turns unsupported markers into read/rewrite decisions with diagnostics plus structured `PdfReadBlockerKind` and `PdfRewriteBlockerKind` entries; encrypted PDFs fail with a clear unsupported diagnostic for parser-supported read/manipulation flows; signed PDFs, form PDFs, complex outline PDFs, complex page-label PDFs, unsupported catalog name-tree PDFs, malformed or unsupported named-destination name-tree PDFs, complex open-action dictionary PDFs, complex viewer-preference PDFs, complex XMP metadata PDFs, complex catalog URI PDFs, tagged PDFs, complex output-intent PDFs, complex embedded-file/associated-file PDFs, complex optional-content PDFs, and active-content PDFs are blocked for rewrite-style manipulation. Simple direct catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array open actions, simple GoTo open-action dictionaries, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, simple associated-file arrays, and simple optional-content metadata are preserved. Creation, validation, redaction, and encrypted reading remain planned | -| Convert | Word to PDF without QuestPDF | Planned | Final direction is Word content mapped into the `OfficeIMO.Pdf` logical model | -| Convert | Current Word to PDF | External bridge | `OfficeIMO.Word.Pdf` currently uses QuestPDF/SkiaSharp and should remain separate from the dependency-free engine | -| Convert | Excel/PowerPoint to PDF | Planned | Later phases after the PDF layout engine matures | +| Convert | Word to PDF without QuestPDF | Partial | `OfficeIMO.Word.Pdf` now defaults to the first-party engine; `PdfSaveOptions.OfficeIMOPageSize` and `OfficeIMOMargins` provide a QuestPDF-free page setup surface using first-party `OfficeIMO.Pdf` geometry types, with explicit `OfficeIMOPageSize` geometry preserved unless `PdfSaveOptions.Orientation` is set; the current native path maps basic Word sections, page setup, Word document background color, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, page breaks, headings including linked headings, paragraphs/runs with common Word/PDF font family requests mapped to standard Helvetica, Times, and Courier PDF families, isolated run color, font-size, superscript/subscript baseline, justified paragraph alignment, text-wrapping breaks, and highlight/background state, paragraph spacing/indents, simple tab stops with leaders/alignment, keep-with-next/keep-lines/widow-control flags, simple shaded and uniform/non-uniform bordered paragraphs, Word horizontal lines and paragraph top/bottom border rules, simple level-0 bullet/decimal lists with rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, footnote/endnote markers, simple tables with supported Word table style presets, rich text runs inside table cells, default and per-cell table margins, table cell spacing, table-level borders, uniform/non-uniform, double, and diagonal cell borders, uniform and non-uniform row heights, row-level break policies, preferred DXA table widths that fit into narrower native PDF column frames, explicit autofit-to-contents tables, cell fills, left/center/right table placement, uniform column and non-uniform per-cell horizontal/vertical alignment, simple merged cells, separated first-row visual table styling and repeated leading table header rows, and linked cells including linked merged cells, paragraph-aligned images, simple VML shapes plus the DrawingML preset flow shapes exposed by `WordShape`, simple body text boxes rendered through first-party panel paragraphs, simple body, table-cell, header, and footer picture content controls rendered as first-party PDF images, simple body repeating-section text items rendered as ordinary first-party PDF paragraphs, simple table-cell repeating-section text items rendered as first-party rich table-cell text, simple header/footer repeating-section text items rendered as first-party zone text, simple header/footer text boxes with extractable text routed through first-party zones, simple inline body/table/header/footer text content controls, simple body-level and table-cell Word check boxes as inspectable PDF AcroForm check boxes with readback and Poppler raster-baseline coverage in the native Word report fixture, simple body-level and table-cell Word dropdown, combo box, and date picker content controls as inspectable PDF AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple default/first/even header and footer text/images/shapes with left/center/right paragraph alignment, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, and simple header/footer table-cell text/images/shapes mapped to first-party zones, simple footnote/endnote markers with end-of-section note text, metadata, and page-number footer settings including Word section page-number starts/styles into `OfficeIMO.Pdf`; the Poppler lane now includes a daily-layout Word fixture covering TOC, margins, page background color, columns including inline column breaks, separator lines, fonts, colors, lists, links, images, headers/footers, and a table inside the column flow. `PdfSaveOptions.Warnings` records unsupported native header/footer visual content such as shapes without supported geometry, text boxes without extractable text, SmartArt, equations, unsupported content controls, and embedded documents, plus unsupported body SmartArt, equations, unsupported header/footer content controls, embedded documents, and unhandled body elements that are not yet faithfully mapped. The old QuestPDF/SkiaSharp engine path has been removed from `OfficeIMO.Word.Pdf`; remaining work is fidelity and coverage in the first-party exporter | +| Convert | Excel to PDF | Partial | `OfficeIMO.Excel.Pdf` provides the first Excel-to-PDF package surface. The exporter maps selected or all visible workbook worksheets into first-party `OfficeIMO.Pdf` headings and tables, honors worksheet print areas, worksheet orientation, worksheet margins, hidden workbook worksheet filtering for default all-sheet exports, hidden worksheet rows and columns, repeated print-title rows through the PDF table header model, manual worksheet row and column page breaks as explicit PDF page breaks while preserving repeated header/title rows across split table chunks, simple worksheet header/footer text zones with first-page and even-page text variants plus page-number, page-count, sheet-name, date, time, workbook file-name, and workbook path tokens, simple line-level header/footer font family/style, font size, and RGB text color when representable as one first-party PDF header/footer line style, and supported header/footer images, worksheet merged cells through PDF table column/row spans, supported worksheet drawing images anchored into exported PDF table cells when the anchor cell is exported and otherwise emitted as PDF flow images in anchor order, supported column/bar/line/area/scatter/radar/pie/doughnut worksheet chart families as first-party vector drawing snapshots when chart data can be read, and common number formats plus basic explicit cell font emphasis, font color, fill color, two-color conditional color-scale fills, conditional data bars, conditional icon-set indicators, horizontal/vertical alignment, simple cell borders including dashed, dotted, dash-dot, double, and diagonal strokes, external cell hyperlinks, internal workbook links as sheet-level PDF named destinations, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, and fit-to-width table sizing through first-party table/rich-text/image primitives; supports explicit page size/margin options through reusable PDF geometry types; can return bytes or write to paths/streams; and now has a Poppler raster baseline for a daily two-sheet workbook covering worksheet header/footer text/images, orientation/margins, merged title cells, fills/borders, number formats, explicit row/column sizing, hidden row/column filtering, anchored worksheet images, chart snapshots, and internal/external links. `ExcelPdfSaveOptions.Warnings` records unsupported or simplified export features such as mixed or rich per-run worksheet header/footer formatting, unsupported or unreadable worksheet/header/footer images, unsupported or unreadable chart snapshots, and row truncation from `MaxRowsPerSheet`. Richer worksheet header/footer formatting beyond the current line-level style mapping, cell-specific internal workbook-link destinations, fit-to-height and automatic multi-page pagination/scaling, richer worksheet image placement fidelity beyond exported table-cell anchors, richer chart fidelity beyond initial column/bar/line/area/scatter/radar/pie/doughnut snapshots, richer cell style fidelity such as additional conditional formats and locale-specific formats, richer merged-cell edge cases, and broader unsupported-feature diagnostics remain roadmap work | +| Convert | PowerPoint to PDF | Planned | Later phases after the PDF layout engine matures | + +Word-to-PDF equation note: simple OMML equations with extractable math text are mapped as static first-party PDF text in body paragraphs, table cells, headers, and footers. Equation warnings in the convert row refer to equations without extractable text. ## Wrapper Guidance diff --git a/OfficeIMO.Examples/Converters/Pdf/Pdf.CustomFonts.cs b/OfficeIMO.Examples/Converters/Pdf/Pdf.CustomFonts.cs index 490c93bc7..9a0629b73 100644 --- a/OfficeIMO.Examples/Converters/Pdf/Pdf.CustomFonts.cs +++ b/OfficeIMO.Examples/Converters/Pdf/Pdf.CustomFonts.cs @@ -1,30 +1,27 @@ using OfficeIMO.Word; using OfficeIMO.Word.Pdf; using System; -using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; namespace OfficeIMO.Examples.Word { internal static partial class Pdf { public static void Example_PdfCustomFonts(string folderPath, bool openWord) { - Console.WriteLine("[*] Creating PDF with custom fonts"); + Console.WriteLine("[*] Creating PDF with a selected host font family"); string docPath = Path.Combine(folderPath, "PdfCustomFonts.docx"); string pdfPath = Path.Combine(folderPath, "PdfCustomFonts.pdf"); - string fontPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf") + string fontFamily = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "Arial" : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? "/System/Library/Fonts/Supplemental/Arial.ttf" - : "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"; + ? "Arial" + : "DejaVu Sans"; using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("File font paragraph").FontFamily = "FileFont"; - document.AddParagraph("Stream font paragraph").FontFamily = "StreamFont"; + document.AddParagraph("PDF paragraph using the selected host font family."); document.Save(); - using var fontStream = File.OpenRead(fontPath); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { - FontFilePaths = new Dictionary { { "FileFont", fontPath } }, - FontStreams = new Dictionary { { "StreamFont", fontStream } } + FontFamily = fontFamily }); } } diff --git a/OfficeIMO.Examples/Converters/Pdf/Pdf.License.cs b/OfficeIMO.Examples/Converters/Pdf/Pdf.FirstPartyOptions.cs similarity index 56% rename from OfficeIMO.Examples/Converters/Pdf/Pdf.License.cs rename to OfficeIMO.Examples/Converters/Pdf/Pdf.FirstPartyOptions.cs index cfa7df63e..ab5fd39d9 100644 --- a/OfficeIMO.Examples/Converters/Pdf/Pdf.License.cs +++ b/OfficeIMO.Examples/Converters/Pdf/Pdf.FirstPartyOptions.cs @@ -1,24 +1,22 @@ using OfficeIMO.Word; using OfficeIMO.Word.Pdf; -using QuestPDF.Infrastructure; using System; using System.IO; namespace OfficeIMO.Examples.Word { internal static partial class Pdf { - public static void Example_SaveAsPdfWithLicense(string folderPath, bool openWord) { - Console.WriteLine("[*] Creating document and exporting to PDF with explicit QuestPDF license"); - string docPath = Path.Combine(folderPath, "PdfWithLicense.docx"); - string pdfPath = Path.Combine(folderPath, "PdfWithLicense.pdf"); - - QuestPDF.Settings.License = null; + public static void Example_SaveAsPdfWithFirstPartyOptions(string folderPath, bool openWord) { + Console.WriteLine("[*] Creating document and exporting to PDF with first-party OfficeIMO options"); + string docPath = Path.Combine(folderPath, "PdfWithFirstPartyOptions.docx"); + string pdfPath = Path.Combine(folderPath, "PdfWithFirstPartyOptions.pdf"); using (WordDocument document = WordDocument.Create(docPath)) { document.AddParagraph("Hello World"); document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - QuestPdfLicenseType = LicenseType.Community + OfficeIMOPageSize = OfficeIMO.Pdf.PageSizes.Letter, + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Narrow }); } diff --git a/OfficeIMO.Examples/Converters/Pdf/Pdf.SaveAsPdf.cs b/OfficeIMO.Examples/Converters/Pdf/Pdf.SaveAsPdf.cs index 21b5114c9..06b75a2e2 100644 --- a/OfficeIMO.Examples/Converters/Pdf/Pdf.SaveAsPdf.cs +++ b/OfficeIMO.Examples/Converters/Pdf/Pdf.SaveAsPdf.cs @@ -1,8 +1,6 @@ using OfficeIMO.Examples.Utils; using OfficeIMO.Word.Pdf; using OfficeIMO.Word; -using QuestPDF.Helpers; -using QuestPDF.Infrastructure; using System; using System.IO; using W = DocumentFormat.OpenXml.Wordprocessing; @@ -57,12 +55,13 @@ public static void Example_SaveAsPdf(string folderPath, bool openWord) { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - PageSize = PageSizes.A4, + OfficeIMOPageSize = OfficeIMO.Pdf.PageSizes.A4, Orientation = PdfPageOrientation.Landscape, - Margin = 2, - MarginUnit = Unit.Centimetre, - MarginBottom = 1, - MarginBottomUnit = Unit.Centimetre + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.FromCentimeters( + left: 2, + top: 2, + right: 2, + bottom: 1) }); } } @@ -78,7 +77,7 @@ public static void Example_SaveAsPdfInMemory(string folderPath, bool openWord) { using (MemoryStream pdfStream = new MemoryStream()) { document.SaveAsPdf(pdfStream, new PdfSaveOptions { - PageSize = new PageSize(300, 500) + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(300, 500) }); File.WriteAllBytes(pdfPath, pdfStream.ToArray()); } diff --git a/OfficeIMO.Examples/Converters/Pdf/Pdf01_SaveAsPdf.cs b/OfficeIMO.Examples/Converters/Pdf/Pdf01_SaveAsPdf.cs index 3f2a01ccc..b947cfc8d 100644 --- a/OfficeIMO.Examples/Converters/Pdf/Pdf01_SaveAsPdf.cs +++ b/OfficeIMO.Examples/Converters/Pdf/Pdf01_SaveAsPdf.cs @@ -47,12 +47,12 @@ public static void Example(string folderPath, bool openWord) { string outputPath = Path.Combine(folderPath, "SaveAsPdf.pdf"); doc.SaveAsPdf(outputPath, new PdfSaveOptions { Orientation = PdfPageOrientation.Portrait, - Margin = 2, - MarginUnit = QuestPDF.Infrastructure.Unit.Centimetre, - MarginTop = 3, - MarginTopUnit = QuestPDF.Infrastructure.Unit.Centimetre, - MarginLeft = 1, - MarginLeftUnit = QuestPDF.Infrastructure.Unit.Centimetre + OfficeIMOPageSize = OfficeIMO.Pdf.PageSizes.A4, + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.FromCentimeters( + left: 1, + top: 3, + right: 2, + bottom: 2) }); Console.WriteLine($"✓ Created: {outputPath}"); @@ -66,4 +66,4 @@ public static void Example(string folderPath, bool openWord) { } } } -} \ No newline at end of file +} diff --git a/OfficeIMO.Examples/Program.cs b/OfficeIMO.Examples/Program.cs index fb02e2436..f19cfc6f6 100644 --- a/OfficeIMO.Examples/Program.cs +++ b/OfficeIMO.Examples/Program.cs @@ -649,7 +649,7 @@ static void Main(string[] args) { // Word.Pdf.Example_SaveAsPdfRelative(folderPath, false); // Word.Pdf.Example_SaveAsPdfWithHyperlinks(folderPath, false); // Word.Pdf.Example_SaveAsPdfWithMetadata(folderPath, false); - // Word.Pdf.Example_SaveAsPdfWithLicense(folderPath, false); + // Word.Pdf.Example_SaveAsPdfWithFirstPartyOptions(folderPath, false); // Word.Pdf.Example_SaveLists(folderPath, false); // Word.Pdf.Example_TableStyles(folderPath, false); // Word.Pdf.Example_PdfCustomFonts(folderPath, false); diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs new file mode 100644 index 000000000..642708999 --- /dev/null +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -0,0 +1,4040 @@ +using OfficeIMO.Drawing; +using PdfCore = OfficeIMO.Pdf; + +namespace OfficeIMO.Excel.Pdf { + /// + /// First-party Excel workbook to PDF conversion helpers. + /// + public static class ExcelPdfConverterExtensions { + /// + /// Converts an Excel workbook to a first-party OfficeIMO PDF document model. + /// + public static PdfCore.PdfDoc ToPdfDocument(this ExcelDocument document, ExcelPdfSaveOptions? options = null) { + if (document == null) { + throw new ArgumentNullException(nameof(document)); + } + + options ??= new ExcelPdfSaveOptions(); + options.Warnings.Clear(); + var pdf = PdfCore.PdfDoc.Create(CreatePdfOptions(options)); + using ExcelDocumentReader reader = document.CreateReader(); + IReadOnlyList sheetNames = GetSheetNames(reader, options); + bool hasExplicitSheetSelection = HasExplicitSheetSelection(options); + IReadOnlyList exportPlans = BuildWorksheetExportPlans(document, reader, sheetNames, options, hasExplicitSheetSelection); + IReadOnlyDictionary sheetDestinations = BuildSheetDestinationMap(exportPlans); + IReadOnlyDictionary cellDestinations = BuildCellDestinationMap(exportPlans); + foreach (WorksheetPdfExportPlan plan in exportPlans) { + object?[,] values = plan.ExportData.Values; + int columns = values.GetLength(1); + + pdf.Section(page => { + ApplyWorksheetPageSetup(page, plan.PageSetup, options); + ApplyWorksheetHeaderFooter(page, plan.HeaderFooter, plan.SheetName, document.FilePath, options); + page.Content(content => content.Item(item => { + item.Bookmark(plan.BookmarkName); + if (options.IncludeSheetHeadings) { + item.H1(plan.SheetName); + } + + IReadOnlyDictionary> imagesByCellReference = CreateWorksheetImageMap(plan); + foreach (WorksheetImageExportData image in plan.Images) { + if (!imagesByCellReference.ContainsKey(NormalizeCellReference(image.CellReference))) { + item.Image(image.Bytes, image.WidthPoints, image.HeightPoints, PdfCore.PdfAlign.Left, spacingBefore: 4, spacingAfter: 6); + } + } + + foreach (WorksheetChartExportData chart in plan.Charts) { + AddWorksheetChart(item, chart); + } + + if (plan.HasTable) { + IReadOnlyList chunks = CreateTableChunks(plan, options, columns); + for (int chunkIndex = 0; chunkIndex < chunks.Count; chunkIndex++) { + TableChunk chunk = chunks[chunkIndex]; + if (chunkIndex > 0) { + item.PageBreak(); + } + + item.Table( + CreatePdfRows(values, plan.ExportData.Styles, plan.ExportData.Hyperlinks, plan.ExportData.CellReferences, plan.ExportData.MergedCells, imagesByCellReference, chunk.RowIndexes, chunk.StartColumn, chunk.ColumnCount, options.EmptyCellText, sheetDestinations, cellDestinations, plan.SheetName), + style: CreateTableStyle(options, plan.PageSetup, chunk.RowIndexes, chunk.HeaderRowCount, plan.ExportData.Styles, plan.ExportData.ConditionalFills, plan.ExportData.ColumnWidths, plan.ExportData.RowHeights, chunk.StartColumn, chunk.ColumnCount)); + } + } + })); + }); + } + + if (exportPlans.Count == 0) { + pdf.H1("Workbook"); + pdf.Table(new[] { new[] { "No worksheet data found." } }, style: new PdfCore.PdfTableStyle { HeaderRowCount = 0 }); + } + + return pdf; + } + + /// + /// Converts an Excel workbook to PDF bytes. + /// + public static byte[] SaveAsPdf(this ExcelDocument document, ExcelPdfSaveOptions? options = null) { + return document.ToPdfDocument(options).ToBytes(); + } + + /// + /// Saves an Excel workbook as a PDF file. + /// + public static void SaveAsPdf(this ExcelDocument document, string path, ExcelPdfSaveOptions? options = null) { + document.ToPdfDocument(options).Save(path); + } + + /// + /// Writes an Excel workbook as PDF to a stream. + /// + public static void SaveAsPdf(this ExcelDocument document, Stream stream, ExcelPdfSaveOptions? options = null) { + document.ToPdfDocument(options).Save(stream); + } + + private static PdfCore.PdfOptions CreatePdfOptions(ExcelPdfSaveOptions options) { + var pdfOptions = new PdfCore.PdfOptions { + CreateOutlineFromHeadings = true + }; + + if (options.PageSize.HasValue) { + pdfOptions.PageSize = options.PageSize.Value; + } + + if (options.Margins.HasValue) { + pdfOptions.Margins = options.Margins.Value; + } + + return pdfOptions; + } + + private static IReadOnlyList BuildWorksheetExportPlans(ExcelDocument document, ExcelDocumentReader reader, IReadOnlyList sheetNames, ExcelPdfSaveOptions options, bool hasExplicitSheetSelection) { + var plans = new List(); + for (int i = 0; i < sheetNames.Count; i++) { + string sheetName = sheetNames[i]; + ExcelSheet? workbookSheet = GetWorkbookSheet(document, sheetName); + if (ShouldSkipWorkbookSheet(workbookSheet, options, hasExplicitSheetSelection)) { + continue; + } + + ExcelSheetReader sheet = reader.GetSheet(sheetName); + ExcelSheetPageSetup? pageSetup = options.UseWorksheetPageSetup ? workbookSheet?.GetPageSetup() : null; + ExcelSheet.HeaderFooterSnapshot? headerFooter = (options.UseWorksheetHeadersAndFooters || options.UseWorksheetHeaderFooterImages) ? workbookSheet?.GetHeaderFooter() : null; + string exportRange = GetExportRange(sheet, workbookSheet, options); + SheetExportData exportData = ReadSheetExportData(sheet, workbookSheet, exportRange, options); + IReadOnlyList images = ReadWorksheetImages(workbookSheet, options, sheetName); + IReadOnlyList charts = ReadWorksheetCharts(workbookSheet, options, sheetName); + IReadOnlyList manualRowBreaks = options.UseWorksheetPageBreaks && workbookSheet != null + ? workbookSheet.GetManualRowPageBreaks() + : Array.Empty(); + IReadOnlyList manualColumnBreaks = options.UseWorksheetPageBreaks && workbookSheet != null + ? workbookSheet.GetManualColumnPageBreaks() + : Array.Empty(); + object?[,] values = exportData.Values; + int rows = values.GetLength(0); + int columns = values.GetLength(1); + bool hasTable = rows > 0 && columns > 0; + if (!hasTable && images.Count == 0 && charts.Count == 0) { + continue; + } + + int exportedRows = options.MaxRowsPerSheet.HasValue + ? Math.Min(rows, options.MaxRowsPerSheet.Value) + : rows; + if (options.MaxRowsPerSheet.HasValue && rows > exportedRows) { + AddWarning( + options, + sheetName, + "WorksheetRows", + $"Worksheet export was truncated from {rows.ToString(CultureInfo.InvariantCulture)} to {exportedRows.ToString(CultureInfo.InvariantCulture)} rows because MaxRowsPerSheet is set."); + } + + plans.Add(new WorksheetPdfExportPlan( + sheetName, + pageSetup, + headerFooter, + exportData, + images, + charts, + hasTable, + exportedRows, + manualRowBreaks, + manualColumnBreaks, + CreateSheetBookmarkName(sheetName, plans.Count + 1))); + } + + return plans; + } + + private static IReadOnlyDictionary BuildSheetDestinationMap(IReadOnlyList exportPlans) { + var destinations = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (WorksheetPdfExportPlan plan in exportPlans) { + destinations[plan.SheetName] = plan.BookmarkName; + } + + return destinations; + } + + private static IReadOnlyDictionary BuildCellDestinationMap(IReadOnlyList exportPlans) { + var targetCells = CollectInternalHyperlinkTargetCells(exportPlans); + if (targetCells.Count == 0) { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var destinations = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (WorksheetPdfExportPlan plan in exportPlans) { + string?[,]? references = plan.ExportData.CellReferences; + if (references == null) { + continue; + } + + int rows = Math.Min(plan.ExportedRows, references.GetLength(0)); + int columns = references.GetLength(1); + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + string? cellReference = references[row, column]; + if (string.IsNullOrWhiteSpace(cellReference)) { + continue; + } + + string key = CreateCellDestinationKey(plan.SheetName, cellReference!); + if (targetCells.Contains(key)) { + destinations[key] = CreateCellBookmarkName(plan.BookmarkName, cellReference!); + } + } + } + } + + return destinations; + } + + private static HashSet CollectInternalHyperlinkTargetCells(IReadOnlyList exportPlans) { + var targetCells = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (WorksheetPdfExportPlan plan in exportPlans) { + ExcelHyperlinkSnapshot?[,]? hyperlinks = plan.ExportData.Hyperlinks; + if (hyperlinks == null) { + continue; + } + + int rows = Math.Min(plan.ExportedRows, hyperlinks.GetLength(0)); + int columns = hyperlinks.GetLength(1); + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + ExcelHyperlinkSnapshot? hyperlink = hyperlinks[row, column]; + if (hyperlink == null || hyperlink.IsExternal) { + continue; + } + + if (TryParseInternalTarget(hyperlink.Target, out string? sheetName, out string? cellReference)) { + targetCells.Add(CreateCellDestinationKey(sheetName!, cellReference!)); + } + } + } + } + + return targetCells; + } + + private static string CreateSheetBookmarkName(string sheetName, int index) { + var builder = new System.Text.StringBuilder(); + foreach (char value in sheetName) { + if (char.IsLetterOrDigit(value)) { + builder.Append(char.ToLowerInvariant(value)); + } else if (builder.Length > 0 && builder[builder.Length - 1] != '-') { + builder.Append('-'); + } + } + + string token = builder.ToString().Trim('-'); + if (token.Length > 40) { + token = token.Substring(0, 40).Trim('-'); + } + + return token.Length == 0 + ? "excel-sheet-" + index.ToString(CultureInfo.InvariantCulture) + : "excel-sheet-" + index.ToString(CultureInfo.InvariantCulture) + "-" + token; + } + + private static string CreateCellBookmarkName(string sheetBookmarkName, string cellReference) { + return sheetBookmarkName + "-" + cellReference.Replace("$", string.Empty).ToLowerInvariant(); + } + + private static string CreateCellDestinationKey(string sheetName, string cellReference) { + return sheetName.Trim() + "!" + cellReference.Replace("$", string.Empty).ToUpperInvariant(); + } + + private static void ApplyWorksheetHeaderFooter(PdfCore.PdfPageCompose page, ExcelSheet.HeaderFooterSnapshot? headerFooter, string sheetName, string? workbookPath, ExcelPdfSaveOptions options) { + if (headerFooter == null) { + return; + } + + WarnUnsupportedHeaderFooterImages(headerFooter, sheetName, options); + + HeaderFooterZones? headerZones = options.UseWorksheetHeadersAndFooters ? ConvertHeaderFooterZones(headerFooter.HeaderLeft, headerFooter.HeaderCenter, headerFooter.HeaderRight, sheetName, workbookPath, options, "header") : null; + var firstHeaderZones = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentFirstPage + ? ConvertHeaderFooterZones(headerFooter.FirstHeaderLeft, headerFooter.FirstHeaderCenter, headerFooter.FirstHeaderRight, sheetName, workbookPath, options, "first-page header") + : null; + var evenHeaderZones = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentOddEven + ? ConvertHeaderFooterZones(headerFooter.EvenHeaderLeft, headerFooter.EvenHeaderCenter, headerFooter.EvenHeaderRight, sheetName, workbookPath, options, "even-page header") + : null; + if (HasAnyText(headerZones) || HasAnyText(firstHeaderZones) || HasAnyText(evenHeaderZones) || HasAnyHeaderImage(headerFooter, options)) { + page.Header(header => { + ApplyHeaderFooterStyle(header, ResolveSharedHeaderFooterStyle(new[] { headerZones, firstHeaderZones, evenHeaderZones }, sheetName, options, "header")); + + if (HasAnyText(headerZones)) { + header.Zones(headerZones!.Left, headerZones.Center, headerZones.Right); + } + + if (HasAnyText(firstHeaderZones)) { + header.FirstPageZones(firstHeaderZones!.Left, firstHeaderZones.Center, firstHeaderZones.Right); + } + + if (HasAnyText(evenHeaderZones)) { + header.EvenPagesZones(evenHeaderZones!.Left, evenHeaderZones.Center, evenHeaderZones.Right); + } + + AddHeaderImage(header, headerFooter.HeaderLeftImage, options, PdfCore.PdfAlign.Left); + AddHeaderImage(header, headerFooter.HeaderCenterImage, options, PdfCore.PdfAlign.Center); + AddHeaderImage(header, headerFooter.HeaderRightImage, options, PdfCore.PdfAlign.Right); + }); + } + + HeaderFooterZones? footerZones = options.UseWorksheetHeadersAndFooters ? ConvertHeaderFooterZones(headerFooter.FooterLeft, headerFooter.FooterCenter, headerFooter.FooterRight, sheetName, workbookPath, options, "footer") : null; + var firstFooterZones = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentFirstPage + ? ConvertHeaderFooterZones(headerFooter.FirstFooterLeft, headerFooter.FirstFooterCenter, headerFooter.FirstFooterRight, sheetName, workbookPath, options, "first-page footer") + : null; + var evenFooterZones = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentOddEven + ? ConvertHeaderFooterZones(headerFooter.EvenFooterLeft, headerFooter.EvenFooterCenter, headerFooter.EvenFooterRight, sheetName, workbookPath, options, "even-page footer") + : null; + if (HasAnyText(footerZones) || HasAnyText(firstFooterZones) || HasAnyText(evenFooterZones) || HasAnyFooterImage(headerFooter, options)) { + page.Footer(footer => { + ApplyHeaderFooterStyle(footer, ResolveSharedHeaderFooterStyle(new[] { footerZones, firstFooterZones, evenFooterZones }, sheetName, options, "footer")); + + if (HasAnyText(footerZones)) { + footer.Zones(footerZones!.Left, footerZones.Center, footerZones.Right); + } + + if (HasAnyText(firstFooterZones)) { + footer.FirstPageZones(firstFooterZones!.Left, firstFooterZones.Center, firstFooterZones.Right); + } + + if (HasAnyText(evenFooterZones)) { + footer.EvenPagesZones(evenFooterZones!.Left, evenFooterZones.Center, evenFooterZones.Right); + } + + AddFooterImage(footer, headerFooter.FooterLeftImage, options, PdfCore.PdfAlign.Left); + AddFooterImage(footer, headerFooter.FooterCenterImage, options, PdfCore.PdfAlign.Center); + AddFooterImage(footer, headerFooter.FooterRightImage, options, PdfCore.PdfAlign.Right); + }); + } + } + + private static bool HasAnyHeaderImage(ExcelSheet.HeaderFooterSnapshot headerFooter, ExcelPdfSaveOptions options) { + return options.UseWorksheetHeaderFooterImages + && (IsPdfSupportedImage(headerFooter.HeaderLeftImage) + || IsPdfSupportedImage(headerFooter.HeaderCenterImage) + || IsPdfSupportedImage(headerFooter.HeaderRightImage)); + } + + private static bool HasAnyFooterImage(ExcelSheet.HeaderFooterSnapshot headerFooter, ExcelPdfSaveOptions options) { + return options.UseWorksheetHeaderFooterImages + && (IsPdfSupportedImage(headerFooter.FooterLeftImage) + || IsPdfSupportedImage(headerFooter.FooterCenterImage) + || IsPdfSupportedImage(headerFooter.FooterRightImage)); + } + + private static void AddHeaderImage(PdfCore.PdfHeaderCompose header, ExcelSheet.HeaderFooterImageSnapshot? image, ExcelPdfSaveOptions options, PdfCore.PdfAlign align) { + if (options.UseWorksheetHeaderFooterImages && IsPdfSupportedImage(image)) { + header.Image(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + } + } + + private static void AddFooterImage(PdfCore.PdfFooterCompose footer, ExcelSheet.HeaderFooterImageSnapshot? image, ExcelPdfSaveOptions options, PdfCore.PdfAlign align) { + if (options.UseWorksheetHeaderFooterImages && IsPdfSupportedImage(image)) { + footer.Image(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + } + } + + private static bool IsPdfSupportedImage(ExcelSheet.HeaderFooterImageSnapshot? image) { + return image != null + && image.Bytes.Length > 0 + && image.WidthPoints > 0D + && image.HeightPoints > 0D + && IsPdfSupportedImageContentType(image.ContentType); + } + + private static bool HasAnyText(params string?[] values) { + foreach (string? value in values) { + if (!string.IsNullOrWhiteSpace(value)) { + return true; + } + } + + return false; + } + + private static bool HasAnyText(HeaderFooterZones? zones) { + if (zones == null) { + return false; + } + + return HasAnyText(zones.Left, zones.Center, zones.Right); + } + + private static void WarnUnsupportedHeaderFooterImages(ExcelSheet.HeaderFooterSnapshot headerFooter, string sheetName, ExcelPdfSaveOptions options) { + if (!options.UseWorksheetHeaderFooterImages) { + return; + } + + WarnUnsupportedHeaderFooterImage(headerFooter.HeaderLeftImage, sheetName, "header left", options); + WarnUnsupportedHeaderFooterImage(headerFooter.HeaderCenterImage, sheetName, "header center", options); + WarnUnsupportedHeaderFooterImage(headerFooter.HeaderRightImage, sheetName, "header right", options); + WarnUnsupportedHeaderFooterImage(headerFooter.FooterLeftImage, sheetName, "footer left", options); + WarnUnsupportedHeaderFooterImage(headerFooter.FooterCenterImage, sheetName, "footer center", options); + WarnUnsupportedHeaderFooterImage(headerFooter.FooterRightImage, sheetName, "footer right", options); + } + + private static void WarnUnsupportedHeaderFooterImage(ExcelSheet.HeaderFooterImageSnapshot? image, string sheetName, string location, ExcelPdfSaveOptions options) { + if (image == null || IsPdfSupportedImage(image)) { + return; + } + + AddWarning( + options, + sheetName, + "WorksheetHeaderFooterImage", + $"The {location} image was not exported because it is not a supported PDF image payload. ContentType='{image.ContentType}', WidthPoints={image.WidthPoints.ToString(CultureInfo.InvariantCulture)}, HeightPoints={image.HeightPoints.ToString(CultureInfo.InvariantCulture)}, Bytes={image.Bytes.Length.ToString(CultureInfo.InvariantCulture)}."); + } + + private static HeaderFooterZones ConvertHeaderFooterZones(string? left, string? center, string? right, string sheetName, string? workbookPath, ExcelPdfSaveOptions options, string scope) { + HeaderFooterZone leftZone = ConvertHeaderFooterText(left, sheetName, workbookPath, options, scope, "left"); + HeaderFooterZone centerZone = ConvertHeaderFooterText(center, sheetName, workbookPath, options, scope, "center"); + HeaderFooterZone rightZone = ConvertHeaderFooterText(right, sheetName, workbookPath, options, scope, "right"); + return new HeaderFooterZones( + leftZone.Text, + centerZone.Text, + rightZone.Text, + ResolveSharedHeaderFooterZoneStyle(new[] { leftZone, centerZone, rightZone }, sheetName, options, scope)); + } + + private static HeaderFooterZone ConvertHeaderFooterText(string? text, string sheetName, string? workbookPath, ExcelPdfSaveOptions options, string scope, string zone) { + if (string.IsNullOrWhiteSpace(text)) { + return HeaderFooterZone.Empty; + } + + var builder = new System.Text.StringBuilder(text!.Length); + var style = new HeaderFooterLineStyle(); + bool unsupportedFormatting = false; + bool canApplyLineStyle = true; + bool hasVisibleContent = false; + DateTime? headerFooterDateTime = null; + for (int i = 0; i < text.Length; i++) { + char ch = text[i]; + if (ch != '&' || i + 1 >= text.Length) { + builder.Append(ch); + if (!char.IsWhiteSpace(ch)) { + hasVisibleContent = true; + } + + continue; + } + + char token = text[++i]; + switch (token) { + case '&': + if (i + 1 < text.Length && char.IsDigit(text[i + 1])) { + if (TryReadHeaderFooterFontSize(text, i + 1, out double fontSize, out int fontSizeEndIndex)) { + ApplyHeaderFooterStyleToken(style, hasVisibleContent, ref unsupportedFormatting, ref canApplyLineStyle, s => s.FontSize = fontSize); + i = fontSizeEndIndex; + } else { + unsupportedFormatting = true; + } + } else { + builder.Append('&'); + hasVisibleContent = true; + } + + break; + case 'P': + builder.Append("{page}"); + hasVisibleContent = true; + break; + case 'N': + builder.Append("{pages}"); + hasVisibleContent = true; + break; + case 'A': + builder.Append(sheetName); + hasVisibleContent = true; + break; + case 'D': + builder.Append(GetHeaderFooterDateTime(options, ref headerFooterDateTime).ToString("d", CultureInfo.CurrentCulture)); + hasVisibleContent = true; + break; + case 'T': + builder.Append(GetHeaderFooterDateTime(options, ref headerFooterDateTime).ToString("t", CultureInfo.CurrentCulture)); + hasVisibleContent = true; + break; + case 'F': + builder.Append(GetHeaderFooterFileName(workbookPath)); + hasVisibleContent = true; + break; + case 'Z': + builder.Append(GetHeaderFooterDirectory(workbookPath)); + hasVisibleContent = true; + break; + case 'G': + break; + case 'B': + ApplyHeaderFooterStyleToken(style, hasVisibleContent, ref unsupportedFormatting, ref canApplyLineStyle, s => s.Bold = !s.Bold); + break; + case 'I': + ApplyHeaderFooterStyleToken(style, hasVisibleContent, ref unsupportedFormatting, ref canApplyLineStyle, s => s.Italic = !s.Italic); + break; + case 'U': + case 'S': + unsupportedFormatting = true; + if (hasVisibleContent) { + canApplyLineStyle = false; + } + + break; + case 'K': + if (TryReadHeaderFooterColor(text, i + 1, out PdfCore.PdfColor color, out int colorEndIndex)) { + ApplyHeaderFooterStyleToken(style, hasVisibleContent, ref unsupportedFormatting, ref canApplyLineStyle, s => s.Color = color); + i = colorEndIndex; + } else { + unsupportedFormatting = true; + i = SkipExcelHeaderFooterColorToken(text, i); + } + + break; + case '"': + if (TryReadHeaderFooterQuotedToken(text, i, out string quotedToken, out int quotedEndIndex) && + TryApplyHeaderFooterFontToken(style, quotedToken)) { + ApplyHeaderFooterStyleToken(style, hasVisibleContent, ref unsupportedFormatting, ref canApplyLineStyle, _ => { }); + i = quotedEndIndex; + } else { + unsupportedFormatting = true; + i = SkipExcelHeaderFooterQuotedToken(text, i); + } + + break; + default: + if (char.IsDigit(token)) { + if (TryReadHeaderFooterFontSize(text, i, out double fontSize, out int fontSizeEndIndex)) { + ApplyHeaderFooterStyleToken(style, hasVisibleContent, ref unsupportedFormatting, ref canApplyLineStyle, s => s.FontSize = fontSize); + i = fontSizeEndIndex; + } else { + unsupportedFormatting = true; + } + } else { + builder.Append(token); + hasVisibleContent = true; + } + break; + } + } + + if (unsupportedFormatting) { + AddWarning( + options, + sheetName, + "WorksheetHeaderFooterFormatting", + $"Excel header/footer formatting in the {scope} {zone} zone was simplified. Text, page tokens, total-page tokens, sheet-name, date/time, and workbook file fields are preserved, but rich formatting is not exported yet."); + } + + string result = builder.ToString().Trim(); + return result.Length == 0 + ? HeaderFooterZone.Empty + : new HeaderFooterZone(result, canApplyLineStyle && style.HasAnyStyle ? style : null); + } + + private static void ApplyHeaderFooterStyleToken(HeaderFooterLineStyle style, bool hasVisibleContent, ref bool unsupportedFormatting, ref bool canApplyLineStyle, Action apply) { + if (hasVisibleContent) { + unsupportedFormatting = true; + canApplyLineStyle = false; + return; + } + + apply(style); + } + + private static HeaderFooterLineStyle? ResolveSharedHeaderFooterZoneStyle(HeaderFooterZone[] zones, string sheetName, ExcelPdfSaveOptions options, string scope) { + HeaderFooterLineStyle? shared = null; + bool hasStyle = false; + bool hasUnstyledText = false; + foreach (HeaderFooterZone zone in zones) { + if (string.IsNullOrWhiteSpace(zone.Text)) { + continue; + } + + if (zone.Style == null) { + hasUnstyledText = true; + continue; + } + + if (!hasStyle) { + shared = zone.Style; + hasStyle = true; + continue; + } + + if (!HeaderFooterLineStyle.Equals(shared, zone.Style)) { + AddMixedHeaderFooterFormattingWarning(options, sheetName, scope); + return null; + } + } + + if (hasStyle && hasUnstyledText) { + AddMixedHeaderFooterFormattingWarning(options, sheetName, scope); + return null; + } + + return shared; + } + + private static HeaderFooterLineStyle? ResolveSharedHeaderFooterStyle(HeaderFooterZones?[] zoneSets, string sheetName, ExcelPdfSaveOptions options, string scope) { + HeaderFooterLineStyle? shared = null; + bool hasStyle = false; + bool hasUnstyledText = false; + foreach (HeaderFooterZones? zones in zoneSets) { + if (!HasAnyText(zones)) { + continue; + } + + if (zones!.Style == null) { + hasUnstyledText = true; + continue; + } + + if (!hasStyle) { + shared = zones.Style; + hasStyle = true; + continue; + } + + if (!HeaderFooterLineStyle.Equals(shared, zones.Style)) { + AddMixedHeaderFooterFormattingWarning(options, sheetName, scope); + return null; + } + } + + if (hasStyle && hasUnstyledText) { + AddMixedHeaderFooterFormattingWarning(options, sheetName, scope); + return null; + } + + return shared; + } + + private static void ApplyHeaderFooterStyle(PdfCore.PdfHeaderCompose header, HeaderFooterLineStyle? style) { + if (style == null) { + return; + } + + if (style.FontSize.HasValue) { + header.FontSize(style.FontSize.Value); + } + + if (style.Color.HasValue) { + header.Color(style.Color.Value); + } + + if (style.Font.HasValue) { + header.Font(style.Font.Value); + } + } + + private static void ApplyHeaderFooterStyle(PdfCore.PdfFooterCompose footer, HeaderFooterLineStyle? style) { + if (style == null) { + return; + } + + if (style.FontSize.HasValue) { + footer.FontSize(style.FontSize.Value); + } + + if (style.Color.HasValue) { + footer.Color(style.Color.Value); + } + + if (style.Font.HasValue) { + footer.Font(style.Font.Value); + } + } + + private static void AddMixedHeaderFooterFormattingWarning(ExcelPdfSaveOptions options, string sheetName, string scope) { + AddWarning( + options, + sheetName, + "WorksheetHeaderFooterFormatting", + $"Excel header/footer formatting in the {scope} uses mixed or partial styles that cannot be represented as one PDF header/footer line style yet. Text is preserved, but rich formatting is simplified."); + } + + private static DateTime GetHeaderFooterDateTime(ExcelPdfSaveOptions options, ref DateTime? dateTime) { + if (!dateTime.HasValue) { + dateTime = options.HeaderFooterDateTimeProvider != null + ? options.HeaderFooterDateTimeProvider() + : DateTime.Now; + } + + return dateTime.Value; + } + + private static string GetHeaderFooterFileName(string? workbookPath) { + if (string.IsNullOrWhiteSpace(workbookPath)) { + return "Workbook"; + } + + string fileName = Path.GetFileName(workbookPath!); + return string.IsNullOrWhiteSpace(fileName) ? "Workbook" : fileName; + } + + private static string GetHeaderFooterDirectory(string? workbookPath) { + if (string.IsNullOrWhiteSpace(workbookPath)) { + return string.Empty; + } + + string? directory = Path.GetDirectoryName(Path.GetFullPath(workbookPath!)); + return directory ?? string.Empty; + } + + private static bool TryReadHeaderFooterFontSize(string text, int startIndex, out double fontSize, out int endIndex) { + fontSize = 0D; + endIndex = startIndex; + int index = startIndex; + while (index < text.Length && char.IsDigit(text[index])) { + index++; + } + + if (index == startIndex) { + return false; + } + + string token = text.Substring(startIndex, index - startIndex); + endIndex = index - 1; + return double.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out fontSize) && fontSize > 0D; + } + + private static bool TryReadHeaderFooterColor(string text, int startIndex, out PdfCore.PdfColor color, out int endIndex) { + color = default; + endIndex = startIndex; + if (startIndex + 6 > text.Length) { + return false; + } + + for (int i = 0; i < 6; i++) { + if (!IsHexDigit(text[startIndex + i])) { + return false; + } + } + + byte red = byte.Parse(text.Substring(startIndex, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + byte green = byte.Parse(text.Substring(startIndex + 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + byte blue = byte.Parse(text.Substring(startIndex + 4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + color = PdfCore.PdfColor.FromRgb(red, green, blue); + endIndex = startIndex + 5; + return true; + } + + private static bool TryReadHeaderFooterQuotedToken(string text, int quoteIndex, out string value, out int endIndex) { + value = string.Empty; + endIndex = quoteIndex; + int closingIndex = quoteIndex + 1; + while (closingIndex < text.Length && text[closingIndex] != '"') { + closingIndex++; + } + + if (closingIndex >= text.Length) { + return false; + } + + value = text.Substring(quoteIndex + 1, closingIndex - quoteIndex - 1); + endIndex = closingIndex; + return true; + } + + private static bool TryApplyHeaderFooterFontToken(HeaderFooterLineStyle style, string token) { + if (string.IsNullOrWhiteSpace(token)) { + return false; + } + + string[] parts = token.Split(new[] { ',' }, 2); + if (!TryMapHeaderFooterFontFamily(parts[0], out HeaderFooterFontFamily family)) { + return false; + } + + style.FontFamily = family; + if (parts.Length > 1) { + string fontStyle = parts[1]; + style.Bold = fontStyle.IndexOf("bold", StringComparison.OrdinalIgnoreCase) >= 0; + style.Italic = fontStyle.IndexOf("italic", StringComparison.OrdinalIgnoreCase) >= 0 || + fontStyle.IndexOf("oblique", StringComparison.OrdinalIgnoreCase) >= 0; + } + + return true; + } + + private static bool TryMapHeaderFooterFontFamily(string value, out HeaderFooterFontFamily family) { + string normalized = NormalizeHeaderFooterFontName(value); + if (normalized == "arial" || normalized == "calibri" || normalized == "helvetica") { + family = HeaderFooterFontFamily.Helvetica; + return true; + } + + if (normalized == "times" || normalized == "timesnewroman") { + family = HeaderFooterFontFamily.Times; + return true; + } + + if (normalized == "courier" || normalized == "couriernew") { + family = HeaderFooterFontFamily.Courier; + return true; + } + + family = HeaderFooterFontFamily.Helvetica; + return false; + } + + private static string NormalizeHeaderFooterFontName(string value) { + var builder = new System.Text.StringBuilder(value.Length); + foreach (char ch in value) { + if (char.IsLetterOrDigit(ch)) { + builder.Append(char.ToLowerInvariant(ch)); + } + } + + return builder.ToString(); + } + + private static int SkipExcelHeaderFooterColorToken(string text, int index) { + int skipped = 0; + while (index + 1 < text.Length && skipped < 6 && IsHexDigit(text[index + 1])) { + index++; + skipped++; + } + + return index; + } + + private static int SkipExcelHeaderFooterQuotedToken(string text, int index) { + while (index + 1 < text.Length) { + index++; + if (text[index] == '"') { + break; + } + } + + return index; + } + + private static int SkipExcelHeaderFooterFontSizeToken(string text, int index) { + while (index + 1 < text.Length && char.IsDigit(text[index + 1])) { + index++; + } + + return index; + } + + private static bool IsHexDigit(char value) { + return (value >= '0' && value <= '9') || + (value >= 'a' && value <= 'f') || + (value >= 'A' && value <= 'F'); + } + + private static void ApplyWorksheetPageSetup(PdfCore.PdfPageCompose page, ExcelSheetPageSetup? pageSetup, ExcelPdfSaveOptions options) { + PdfCore.PageSize? pageSize = options.PageSize; + if (pageSetup?.Orientation == ExcelPageOrientation.Landscape) { + pageSize = (pageSize ?? PdfCore.PageSizes.Letter).Landscape(); + } else if (pageSetup?.Orientation == ExcelPageOrientation.Portrait) { + pageSize = (pageSize ?? PdfCore.PageSizes.Letter).Portrait(); + } + + if (pageSize.HasValue) { + page.Size(pageSize.Value); + } + + if (options.Margins.HasValue) { + page.Margin(options.Margins.Value); + } else if (pageSetup?.Margins != null) { + page.Margin(ToPdfMargins(pageSetup.Margins)); + } + } + + private static PdfCore.PageMargins ToPdfMargins(ExcelSheetPageMargins margins) { + return PdfCore.PageMargins.FromInches(margins.Left, margins.Top, margins.Right, margins.Bottom); + } + + private static IReadOnlyList GetSheetNames(ExcelDocumentReader reader, ExcelPdfSaveOptions options) { + IReadOnlyList requestedNames = options.SheetNames ?? Array.Empty(); + if (requestedNames.Count == 0) { + return reader.GetSheetNames(); + } + + var names = new List(requestedNames.Count); + foreach (string name in requestedNames) { + if (string.IsNullOrWhiteSpace(name)) { + throw new ArgumentException("Sheet names cannot contain null, empty, or whitespace values.", nameof(options)); + } + + reader.GetSheet(name); + names.Add(name); + } + + return names; + } + + private static bool HasExplicitSheetSelection(ExcelPdfSaveOptions options) { + return options.SheetNames != null && options.SheetNames.Count > 0; + } + + private static bool ShouldSkipWorkbookSheet(ExcelSheet? workbookSheet, ExcelPdfSaveOptions options, bool hasExplicitSheetSelection) { + return !hasExplicitSheetSelection + && options.RespectWorkbookSheetVisibility + && workbookSheet?.Hidden == true; + } + + private static IReadOnlyList ReadWorksheetImages(ExcelSheet? workbookSheet, ExcelPdfSaveOptions options, string sheetName) { + if (!options.UseWorksheetImages || workbookSheet == null) { + return Array.Empty(); + } + + var images = new List(); + foreach (ExcelImage image in workbookSheet.Images.OrderBy(image => image.RowIndex).ThenBy(image => image.ColumnIndex)) { + if (!IsPdfSupportedImageContentType(image.ContentType)) { + AddWarning( + options, + sheetName, + "WorksheetImage", + $"Worksheet image anchored at {A1.CellReference(image.RowIndex, image.ColumnIndex)} was not exported because content type '{image.ContentType}' is not supported by the first-party PDF image writer."); + continue; + } + + byte[] bytes = image.GetBytes(); + if (bytes.Length == 0 || image.WidthPixels <= 0 || image.HeightPixels <= 0) { + AddWarning( + options, + sheetName, + "WorksheetImage", + $"Worksheet image anchored at {A1.CellReference(image.RowIndex, image.ColumnIndex)} was not exported because it has empty image bytes or non-positive dimensions."); + continue; + } + + images.Add(new WorksheetImageExportData(bytes, PixelsToPoints(image.WidthPixels), PixelsToPoints(image.HeightPixels), A1.CellReference(image.RowIndex, image.ColumnIndex))); + } + + return images; + } + + private static IReadOnlyList ReadWorksheetCharts(ExcelSheet? workbookSheet, ExcelPdfSaveOptions options, string sheetName) { + if (!options.UseWorksheetCharts || workbookSheet == null) { + return Array.Empty(); + } + + var charts = new List(); + foreach (ExcelChart chart in workbookSheet.Charts) { + if (!chart.TryGetSnapshot(out ExcelChartSnapshot snapshot)) { + AddWarning( + options, + sheetName, + "WorksheetChart", + $"Worksheet chart '{chart.Name}' was not exported because its chart data could not be read into a first-party PDF snapshot."); + continue; + } + + if (IsSupportedChartSnapshot(snapshot)) { + charts.Add(new WorksheetChartExportData(snapshot)); + } else { + AddWarning( + options, + sheetName, + "WorksheetChart", + $"Worksheet chart '{GetChartDisplayName(snapshot)}' was not exported because chart type '{snapshot.ChartType}' is not supported by the first-party PDF chart snapshot renderer yet."); + } + } + + return charts + .OrderBy(chart => chart.Snapshot.RowIndex) + .ThenBy(chart => chart.Snapshot.ColumnIndex) + .ToList(); + } + + private static bool IsSupportedChartSnapshot(ExcelChartSnapshot snapshot) { + return IsColumnChart(snapshot.ChartType) + || IsBarChart(snapshot.ChartType) + || IsLineChart(snapshot.ChartType) + || IsAreaChart(snapshot.ChartType) + || IsScatterChart(snapshot.ChartType) + || IsRadarChart(snapshot.ChartType) + || IsPieChart(snapshot.ChartType) + || IsDoughnutChart(snapshot.ChartType); + } + + private static string GetChartDisplayName(ExcelChartSnapshot snapshot) { + if (!string.IsNullOrWhiteSpace(snapshot.Title)) { + return snapshot.Title!; + } + + return string.IsNullOrWhiteSpace(snapshot.Name) ? snapshot.ChartType.ToString() : snapshot.Name; + } + + private static void AddWarning(ExcelPdfSaveOptions options, string sheetName, string feature, string message) { + options.Warnings.Add(new ExcelPdfExportWarning(sheetName, feature, message)); + } + + private static bool IsPdfSupportedImageContentType(string contentType) { + return string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase) + || string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase) + || string.Equals(contentType, "image/jpg", StringComparison.OrdinalIgnoreCase); + } + + private static double PixelsToPoints(int pixels) { + return pixels * 72D / 96D; + } + + private enum HeaderFooterFontFamily { + Helvetica, + Times, + Courier + } + + private sealed class HeaderFooterZone { + internal static readonly HeaderFooterZone Empty = new HeaderFooterZone(null, null); + + internal HeaderFooterZone(string? text, HeaderFooterLineStyle? style) { + Text = text; + Style = style; + } + + internal string? Text { get; } + + internal HeaderFooterLineStyle? Style { get; } + } + + private sealed class HeaderFooterZones { + internal HeaderFooterZones(string? left, string? center, string? right, HeaderFooterLineStyle? style) { + Left = left; + Center = center; + Right = right; + Style = style; + } + + internal string? Left { get; } + + internal string? Center { get; } + + internal string? Right { get; } + + internal HeaderFooterLineStyle? Style { get; } + } + + private sealed class HeaderFooterLineStyle { + internal double? FontSize { get; set; } + + internal PdfCore.PdfColor? Color { get; set; } + + internal HeaderFooterFontFamily? FontFamily { get; set; } + + internal bool Bold { get; set; } + + internal bool Italic { get; set; } + + internal PdfCore.PdfStandardFont? Font { + get { + if (!FontFamily.HasValue && !Bold && !Italic) { + return null; + } + + HeaderFooterFontFamily family = FontFamily ?? HeaderFooterFontFamily.Helvetica; + switch (family) { + case HeaderFooterFontFamily.Times: + if (Bold && Italic) return PdfCore.PdfStandardFont.TimesBoldItalic; + if (Bold) return PdfCore.PdfStandardFont.TimesBold; + if (Italic) return PdfCore.PdfStandardFont.TimesItalic; + return PdfCore.PdfStandardFont.TimesRoman; + case HeaderFooterFontFamily.Courier: + if (Bold && Italic) return PdfCore.PdfStandardFont.CourierBoldOblique; + if (Bold) return PdfCore.PdfStandardFont.CourierBold; + if (Italic) return PdfCore.PdfStandardFont.CourierOblique; + return PdfCore.PdfStandardFont.Courier; + default: + if (Bold && Italic) return PdfCore.PdfStandardFont.HelveticaBoldOblique; + if (Bold) return PdfCore.PdfStandardFont.HelveticaBold; + if (Italic) return PdfCore.PdfStandardFont.HelveticaOblique; + return PdfCore.PdfStandardFont.Helvetica; + } + } + } + + internal bool HasAnyStyle { + get { + PdfCore.PdfStandardFont? font = Font; + return FontSize.HasValue || Color.HasValue || (font.HasValue && font.Value != PdfCore.PdfStandardFont.Helvetica); + } + } + + internal static bool Equals(HeaderFooterLineStyle? left, HeaderFooterLineStyle? right) { + if (ReferenceEquals(left, right)) { + return true; + } + + if (left == null || right == null) { + return false; + } + + return Nullable.Equals(left.FontSize, right.FontSize) + && Nullable.Equals(left.Color, right.Color) + && Nullable.Equals(left.Font, right.Font); + } + } + + private static void AddWorksheetChart(PdfCore.PdfItemCompose item, WorksheetChartExportData chart) { + ExcelChartSnapshot snapshot = chart.Snapshot; + string title = string.IsNullOrWhiteSpace(snapshot.Title) ? snapshot.Name : snapshot.Title!; + if (!string.IsNullOrWhiteSpace(title)) { + item.H2(title, PdfCore.PdfAlign.Left, PdfCore.PdfColor.FromRgb(31, 78, 121)); + } + + item.Drawing(CreateChartDrawing(snapshot), PdfCore.PdfAlign.Left, spacingBefore: 2, spacingAfter: 6); + item.Table(CreateChartLegendRows(snapshot), PdfCore.PdfAlign.Left, CreateChartLegendStyle(GetChartLegendColorCount(snapshot))); + } + + private static OfficeDrawing CreateChartDrawing(ExcelChartSnapshot snapshot) { + double width = Math.Min(420D, Math.Max(240D, PixelsToPoints(snapshot.WidthPixels))); + double height = Math.Min(260D, Math.Max(150D, PixelsToPoints(snapshot.HeightPixels))); + var drawing = new OfficeDrawing(width, height); + + AddShape(drawing, OfficeShape.Rectangle(width, height), 0, 0, OfficeColor.FromRgb(250, 252, 255), OfficeColor.FromRgb(183, 194, 207), 0.75); + + if (IsPieChart(snapshot.ChartType) || IsDoughnutChart(snapshot.ChartType)) { + AddPieSeries(drawing, snapshot, width, height, IsDoughnutChart(snapshot.ChartType)); + return drawing; + } + + if (IsRadarChart(snapshot.ChartType)) { + AddRadarSeries(drawing, snapshot, width, height); + return drawing; + } + + double plotLeft = 36D; + double plotTop = 18D; + double plotRight = 12D; + double plotBottom = 28D; + double plotWidth = Math.Max(20D, width - plotLeft - plotRight); + double plotHeight = Math.Max(20D, height - plotTop - plotBottom); + double plotBottomY = plotTop + plotHeight; + + AddShape(drawing, OfficeShape.Line(0, 0, plotWidth, 0), plotLeft, plotBottomY, null, OfficeColor.FromRgb(80, 90, 100), 0.75); + AddShape(drawing, OfficeShape.Line(0, 0, 0, plotHeight), plotLeft, plotTop, null, OfficeColor.FromRgb(80, 90, 100), 0.75); + for (int i = 1; i <= 3; i++) { + double y = plotTop + (plotHeight * i / 4D); + AddShape(drawing, OfficeShape.Line(0, 0, plotWidth, 0), plotLeft, y, null, OfficeColor.FromRgb(226, 232, 240), 0.5); + } + + if (IsAreaChart(snapshot.ChartType)) { + AddAreaSeries(drawing, snapshot, plotLeft, plotTop, plotWidth, plotHeight); + } else if (IsScatterChart(snapshot.ChartType)) { + AddScatterSeries(drawing, snapshot, plotLeft, plotTop, plotWidth, plotHeight); + } else if (IsLineChart(snapshot.ChartType)) { + AddLineSeries(drawing, snapshot, plotLeft, plotTop, plotWidth, plotHeight); + } else { + AddBarSeries(drawing, snapshot, plotLeft, plotTop, plotWidth, plotHeight); + } + + return drawing; + } + + private static void AddBarSeries(OfficeDrawing drawing, ExcelChartSnapshot snapshot, double plotLeft, double plotTop, double plotWidth, double plotHeight) { + IReadOnlyList categories = snapshot.Data.Categories; + IReadOnlyList series = snapshot.Data.Series; + if (categories.Count == 0 || series.Count == 0) { + return; + } + + double max = GetPositiveMax(series); + double slot = plotWidth / categories.Count; + double groupWidth = slot * 0.68D; + double barWidth = Math.Max(2D, groupWidth / series.Count); + bool horizontal = IsBarChart(snapshot.ChartType); + + for (int category = 0; category < categories.Count; category++) { + for (int s = 0; s < series.Count; s++) { + double value = Math.Max(0D, GetSeriesValue(series[s], category)); + OfficeColor color = GetChartSeriesColor(s); + if (horizontal) { + double rowHeight = Math.Max(2D, plotHeight / categories.Count * 0.68D / series.Count); + double y = plotTop + (plotHeight / categories.Count * category) + (plotHeight / categories.Count * 0.16D) + (rowHeight * s); + double w = Math.Max(1D, plotWidth * value / max); + AddShape(drawing, OfficeShape.Rectangle(w, rowHeight), plotLeft, y, color, null, 0); + } else { + double x = plotLeft + (slot * category) + ((slot - groupWidth) / 2D) + (barWidth * s); + double h = Math.Max(1D, plotHeight * value / max); + double y = plotTop + plotHeight - h; + AddShape(drawing, OfficeShape.Rectangle(barWidth * 0.88D, h), x, y, color, null, 0); + } + } + } + } + + private static void AddAreaSeries(OfficeDrawing drawing, ExcelChartSnapshot snapshot, double plotLeft, double plotTop, double plotWidth, double plotHeight) { + IReadOnlyList categories = snapshot.Data.Categories; + IReadOnlyList series = snapshot.Data.Series; + if (categories.Count < 2 || series.Count == 0) { + return; + } + + bool stacked = IsStackedAreaChart(snapshot.ChartType) || IsPercentStackedAreaChart(snapshot.ChartType); + bool percentStacked = IsPercentStackedAreaChart(snapshot.ChartType); + double max = percentStacked ? 1D : stacked ? GetPositiveStackedMax(series, categories.Count) : GetPositiveMax(series); + double step = plotWidth / (categories.Count - 1); + var cumulative = new double[categories.Count]; + + for (int s = 0; s < series.Count; s++) { + OfficeColor color = GetChartSeriesColor(s); + var topPoints = new List(categories.Count); + var bottomPoints = new List(categories.Count); + + for (int i = 0; i < categories.Count; i++) { + double rawValue = Math.Max(0D, GetSeriesValue(series[s], i)); + double baseline = stacked ? cumulative[i] : 0D; + double topValue = baseline + rawValue; + + if (percentStacked) { + double total = GetPositiveCategoryTotal(series, i); + baseline = total <= 0D ? 0D : baseline / total; + topValue = total <= 0D ? 0D : topValue / total; + } + + double x = plotLeft + step * i; + topPoints.Add(new OfficePoint(x, ToPlotY(topValue, max, plotTop, plotHeight))); + bottomPoints.Add(new OfficePoint(x, ToPlotY(baseline, max, plotTop, plotHeight))); + } + + var areaPoints = new List(topPoints.Count + bottomPoints.Count); + areaPoints.AddRange(topPoints); + for (int i = bottomPoints.Count - 1; i >= 0; i--) { + areaPoints.Add(bottomPoints[i]); + } + + AddPolygonShape(drawing, areaPoints, color, color, 0.5D, 0.32D); + AddPointLine(drawing, topPoints, color, 1.4D); + + if (stacked) { + for (int i = 0; i < categories.Count; i++) { + cumulative[i] += Math.Max(0D, GetSeriesValue(series[s], i)); + } + } + } + } + + private static void AddLineSeries(OfficeDrawing drawing, ExcelChartSnapshot snapshot, double plotLeft, double plotTop, double plotWidth, double plotHeight) { + IReadOnlyList categories = snapshot.Data.Categories; + IReadOnlyList series = snapshot.Data.Series; + if (categories.Count < 2 || series.Count == 0) { + return; + } + + double max = GetPositiveMax(series); + double step = plotWidth / (categories.Count - 1); + for (int s = 0; s < series.Count; s++) { + OfficeColor color = GetChartSeriesColor(s); + for (int i = 1; i < categories.Count; i++) { + double x1 = plotLeft + step * (i - 1); + double y1 = plotTop + plotHeight - (plotHeight * Math.Max(0D, GetSeriesValue(series[s], i - 1)) / max); + double x2 = plotLeft + step * i; + double y2 = plotTop + plotHeight - (plotHeight * Math.Max(0D, GetSeriesValue(series[s], i)) / max); + double minX = Math.Min(x1, x2); + double minY = Math.Min(y1, y2); + AddShape(drawing, OfficeShape.Line(x1 - minX, y1 - minY, x2 - minX, y2 - minY), minX, minY, null, color, 1.75); + } + + for (int i = 0; i < categories.Count; i++) { + double x = plotLeft + step * i - 2D; + double y = plotTop + plotHeight - (plotHeight * Math.Max(0D, GetSeriesValue(series[s], i)) / max) - 2D; + AddShape(drawing, OfficeShape.Ellipse(4D, 4D), x, y, OfficeColor.White, color, 1D); + } + } + } + + private static void AddScatterSeries(OfficeDrawing drawing, ExcelChartSnapshot snapshot, double plotLeft, double plotTop, double plotWidth, double plotHeight) { + IReadOnlyList categories = snapshot.Data.Categories; + IReadOnlyList series = snapshot.Data.Series; + if (categories.Count == 0 || series.Count == 0) { + return; + } + + IReadOnlyList xValues = GetScatterXValues(categories); + (double minX, double maxX) = GetFiniteRange(xValues); + (double minY, double maxY) = GetFiniteSeriesRange(series); + for (int s = 0; s < series.Count; s++) { + OfficeColor color = GetChartSeriesColor(s); + var points = new List(categories.Count); + for (int i = 0; i < categories.Count; i++) { + double yValue = GetSeriesValue(series[s], i); + double x = ToPlotX(xValues[i], minX, maxX, plotLeft, plotWidth); + double y = ToPlotY(yValue, minY, maxY, plotTop, plotHeight); + points.Add(new OfficePoint(x, y)); + } + + AddPointLine(drawing, points, color, 1.25D); + for (int i = 0; i < points.Count; i++) { + OfficePoint point = points[i]; + AddShape(drawing, OfficeShape.Ellipse(5D, 5D), point.X - 2.5D, point.Y - 2.5D, OfficeColor.White, color, 1.25D); + } + } + } + + private static void AddRadarSeries(OfficeDrawing drawing, ExcelChartSnapshot snapshot, double width, double height) { + IReadOnlyList categories = snapshot.Data.Categories; + IReadOnlyList series = snapshot.Data.Series; + if (categories.Count < 3 || series.Count == 0) { + return; + } + + double centerX = width / 2D; + double centerY = height / 2D; + double radius = Math.Max(36D, Math.Min(width - 52D, height - 42D) / 2D); + double max = GetPositiveMax(series); + + for (int ring = 1; ring <= 4; ring++) { + double ringRadius = radius * ring / 4D; + IReadOnlyList ringPoints = CreateRadarPoints(categories.Count, centerX, centerY, ringRadius); + AddPolygonShape(drawing, ringPoints, null, OfficeColor.FromRgb(226, 232, 240), 0.5D); + } + + IReadOnlyList outerPoints = CreateRadarPoints(categories.Count, centerX, centerY, radius); + for (int i = 0; i < outerPoints.Count; i++) { + OfficePoint point = outerPoints[i]; + double minX = Math.Min(centerX, point.X); + double minY = Math.Min(centerY, point.Y); + AddShape( + drawing, + OfficeShape.Line(centerX - minX, centerY - minY, point.X - minX, point.Y - minY), + minX, + minY, + null, + OfficeColor.FromRgb(203, 213, 225), + 0.5D); + } + + for (int s = 0; s < series.Count; s++) { + OfficeColor color = GetChartSeriesColor(s); + var points = new List(categories.Count); + for (int i = 0; i < categories.Count; i++) { + double value = Math.Max(0D, GetSeriesValue(series[s], i)); + double pointRadius = radius * Math.Min(1D, value / max); + points.Add(CreateRadarPoint(i, categories.Count, centerX, centerY, pointRadius)); + } + + AddPolygonShape(drawing, points, color, color, 1D, 0.18D); + for (int i = 0; i < points.Count; i++) { + OfficePoint point = points[i]; + AddShape(drawing, OfficeShape.Ellipse(4D, 4D), point.X - 2D, point.Y - 2D, OfficeColor.White, color, 1D); + } + } + } + + private static void AddPieSeries(OfficeDrawing drawing, ExcelChartSnapshot snapshot, double width, double height, bool doughnut) { + IReadOnlyList categories = snapshot.Data.Categories; + IReadOnlyList series = snapshot.Data.Series; + if (categories.Count == 0 || series.Count == 0) { + return; + } + + ExcelChartSeries values = series[0]; + double total = 0D; + for (int i = 0; i < categories.Count; i++) { + double value = GetSeriesValue(values, i); + if (!double.IsNaN(value) && !double.IsInfinity(value) && value > 0D) { + total += value; + } + } + + if (total <= 0D) { + return; + } + + double radius = Math.Max(36D, Math.Min(width - 48D, height - 36D) / 2D); + double centerX = width / 2D; + double centerY = height / 2D; + double start = -Math.PI / 2D; + for (int i = 0; i < categories.Count; i++) { + double value = Math.Max(0D, GetSeriesValue(values, i)); + if (value <= 0D) { + continue; + } + + double sweep = value / total * Math.PI * 2D; + double end = start + sweep; + var points = new List { + new OfficePoint(centerX, centerY) + }; + int segments = Math.Max(2, (int)Math.Ceiling(sweep / (Math.PI / 18D))); + for (int segment = 0; segment <= segments; segment++) { + double angle = start + (sweep * segment / segments); + points.Add(new OfficePoint( + centerX + Math.Cos(angle) * radius, + centerY + Math.Sin(angle) * radius)); + } + + AddPolygonShape(drawing, points, GetChartSeriesColor(i), OfficeColor.White, 0.5D); + start = end; + } + + if (doughnut) { + double innerDiameter = radius * 1.02D; + AddShape( + drawing, + OfficeShape.Ellipse(innerDiameter, innerDiameter), + centerX - innerDiameter / 2D, + centerY - innerDiameter / 2D, + OfficeColor.FromRgb(250, 252, 255), + null, + 0D); + } + } + + private static string[][] CreateChartLegendRows(ExcelChartSnapshot snapshot) { + if (IsPieChart(snapshot.ChartType) || IsDoughnutChart(snapshot.ChartType)) { + return CreatePieChartLegendRows(snapshot); + } + + var rows = new List { + new[] { "Series", "Values" } + }; + + foreach (ExcelChartSeries series in snapshot.Data.Series) { + rows.Add(new[] { + series.Name, + string.Join(", ", series.Values.Select(value => value.ToString("0.##", CultureInfo.InvariantCulture))) + }); + } + + return rows.ToArray(); + } + + private static string[][] CreatePieChartLegendRows(ExcelChartSnapshot snapshot) { + var rows = new List { + new[] { "Category", "Value" } + }; + + IReadOnlyList categories = snapshot.Data.Categories; + IReadOnlyList series = snapshot.Data.Series; + ExcelChartSeries? values = series.Count > 0 ? series[0] : null; + for (int i = 0; i < categories.Count; i++) { + string category = string.IsNullOrWhiteSpace(categories[i]) + ? "Slice " + (i + 1).ToString(CultureInfo.InvariantCulture) + : categories[i]; + rows.Add(new[] { + category, + values == null ? string.Empty : GetSeriesValue(values, i).ToString("0.##", CultureInfo.InvariantCulture) + }); + } + + return rows.ToArray(); + } + + private static int GetChartLegendColorCount(ExcelChartSnapshot snapshot) { + if (IsPieChart(snapshot.ChartType) || IsDoughnutChart(snapshot.ChartType)) { + return snapshot.Data.Categories.Count; + } + + return snapshot.Data.Series.Count; + } + + private static PdfCore.PdfTableStyle CreateChartLegendStyle(int colorCount) { + var style = new PdfCore.PdfTableStyle { + HeaderRowCount = 1, + FontSize = 8.5, + HeaderFontSize = 8.5, + CellPaddingX = 4, + CellPaddingY = 2, + BorderColor = PdfCore.PdfColor.FromRgb(203, 213, 225), + HeaderFill = PdfCore.PdfColor.FromRgb(239, 246, 255), + ColumnWidthWeights = new List { 0.7D, 1.3D }, + AutoFitColumns = false, + MaxWidth = 300D, + SpacingAfter = 6 + }; + + var fills = new Dictionary<(int Row, int Column), PdfCore.PdfColor>(); + for (int i = 0; i < colorCount; i++) { + fills[(i + 1, 0)] = PdfCore.PdfColor.FromOfficeColor(GetChartSeriesColor(i)); + } + style.CellFills = fills; + return style; + } + + private static double GetPositiveMax(IReadOnlyList series) { + double max = 0D; + foreach (ExcelChartSeries item in series) { + foreach (double value in item.Values) { + if (!double.IsNaN(value) && !double.IsInfinity(value) && value > max) { + max = value; + } + } + } + + return max <= 0D ? 1D : max; + } + + private static double GetPositiveStackedMax(IReadOnlyList series, int categoryCount) { + double max = 0D; + for (int i = 0; i < categoryCount; i++) { + double total = GetPositiveCategoryTotal(series, i); + if (total > max) { + max = total; + } + } + + return max <= 0D ? 1D : max; + } + + private static double GetPositiveCategoryTotal(IReadOnlyList series, int categoryIndex) { + double total = 0D; + for (int s = 0; s < series.Count; s++) { + total += Math.Max(0D, GetSeriesValue(series[s], categoryIndex)); + } + + return total; + } + + private static double GetSeriesValue(ExcelChartSeries series, int index) { + double value = index >= 0 && index < series.Values.Count ? series.Values[index] : 0D; + return double.IsNaN(value) || double.IsInfinity(value) ? 0D : value; + } + + private static double ToPlotY(double value, double max, double plotTop, double plotHeight) { + double ratio = max <= 0D ? 0D : Math.Max(0D, value) / max; + if (ratio > 1D) { + ratio = 1D; + } + + return plotTop + plotHeight - (plotHeight * ratio); + } + + private static double ToPlotY(double value, double min, double max, double plotTop, double plotHeight) { + double range = max - min; + double ratio = range <= 0D ? 0.5D : (value - min) / range; + if (ratio < 0D) { + ratio = 0D; + } else if (ratio > 1D) { + ratio = 1D; + } + + return plotTop + plotHeight - (plotHeight * ratio); + } + + private static double ToPlotX(double value, double min, double max, double plotLeft, double plotWidth) { + double range = max - min; + double ratio = range <= 0D ? 0.5D : (value - min) / range; + if (ratio < 0D) { + ratio = 0D; + } else if (ratio > 1D) { + ratio = 1D; + } + + return plotLeft + plotWidth * ratio; + } + + private static IReadOnlyList GetScatterXValues(IReadOnlyList categories) { + var values = new double[categories.Count]; + for (int i = 0; i < categories.Count; i++) { + if (double.TryParse(categories[i], NumberStyles.Float, CultureInfo.InvariantCulture, out double value) && + !double.IsNaN(value) && + !double.IsInfinity(value)) { + values[i] = value; + } else { + values[i] = i + 1D; + } + } + + return values; + } + + private static IReadOnlyList CreateRadarPoints(int count, double centerX, double centerY, double radius) { + var points = new List(count); + for (int i = 0; i < count; i++) { + points.Add(CreateRadarPoint(i, count, centerX, centerY, radius)); + } + + return points; + } + + private static OfficePoint CreateRadarPoint(int index, int count, double centerX, double centerY, double radius) { + double angle = -Math.PI / 2D + Math.PI * 2D * index / count; + return new OfficePoint(centerX + Math.Cos(angle) * radius, centerY + Math.Sin(angle) * radius); + } + + private static (double Min, double Max) GetFiniteSeriesRange(IReadOnlyList series) { + bool any = false; + double min = 0D; + double max = 0D; + foreach (ExcelChartSeries item in series) { + foreach (double value in item.Values) { + if (double.IsNaN(value) || double.IsInfinity(value)) { + continue; + } + + if (!any) { + min = value; + max = value; + any = true; + } else { + if (value < min) min = value; + if (value > max) max = value; + } + } + } + + return any ? ExpandFlatRange(min, max) : (0D, 1D); + } + + private static (double Min, double Max) GetFiniteRange(IReadOnlyList values) { + bool any = false; + double min = 0D; + double max = 0D; + foreach (double value in values) { + if (double.IsNaN(value) || double.IsInfinity(value)) { + continue; + } + + if (!any) { + min = value; + max = value; + any = true; + } else { + if (value < min) min = value; + if (value > max) max = value; + } + } + + return any ? ExpandFlatRange(min, max) : (0D, 1D); + } + + private static (double Min, double Max) ExpandFlatRange(double min, double max) { + if (max > min) { + return (min, max); + } + + double padding = Math.Abs(min) > 1D ? Math.Abs(min) * 0.1D : 1D; + return (min - padding, max + padding); + } + + private static OfficeColor GetChartSeriesColor(int index) { + switch (index % 6) { + case 0: + return OfficeColor.FromRgb(31, 78, 121); + case 1: + return OfficeColor.FromRgb(47, 111, 62); + case 2: + return OfficeColor.FromRgb(184, 90, 35); + case 3: + return OfficeColor.FromRgb(112, 48, 160); + case 4: + return OfficeColor.FromRgb(37, 99, 235); + default: + return OfficeColor.FromRgb(120, 113, 108); + } + } + + private static void AddShape(OfficeDrawing drawing, OfficeShape shape, double x, double y, OfficeColor? fill, OfficeColor? stroke, double strokeWidth) { + shape.FillColor = fill; + shape.StrokeColor = stroke; + shape.StrokeWidth = strokeWidth; + drawing.AddShape(shape, x, y); + } + + private static void AddPolygonShape(OfficeDrawing drawing, IReadOnlyList points, OfficeColor? fill, OfficeColor? stroke, double strokeWidth, double? fillOpacity = null) { + if (points.Count < 3) { + return; + } + + double minX = points[0].X; + double minY = points[0].Y; + double maxX = points[0].X; + double maxY = points[0].Y; + for (int i = 1; i < points.Count; i++) { + OfficePoint point = points[i]; + if (point.X < minX) minX = point.X; + if (point.Y < minY) minY = point.Y; + if (point.X > maxX) maxX = point.X; + if (point.Y > maxY) maxY = point.Y; + } + + if (maxX <= minX || maxY <= minY) { + return; + } + + OfficeShape shape = OfficeShape.Polygon(points); + shape.FillOpacity = fillOpacity; + AddShape(drawing, shape, minX, minY, fill, stroke, strokeWidth); + } + + private static void AddPointLine(OfficeDrawing drawing, IReadOnlyList points, OfficeColor color, double strokeWidth) { + for (int i = 1; i < points.Count; i++) { + OfficePoint previous = points[i - 1]; + OfficePoint current = points[i]; + if (previous.Equals(current)) { + continue; + } + + double minX = Math.Min(previous.X, current.X); + double minY = Math.Min(previous.Y, current.Y); + AddShape( + drawing, + OfficeShape.Line(previous.X - minX, previous.Y - minY, current.X - minX, current.Y - minY), + minX, + minY, + null, + color, + strokeWidth); + } + } + + private static bool IsColumnChart(ExcelChartType type) { + return type == ExcelChartType.ColumnClustered + || type == ExcelChartType.ColumnStacked + || type == ExcelChartType.ColumnStacked100 + || type == ExcelChartType.Column3DClustered + || type == ExcelChartType.Column3DStacked + || type == ExcelChartType.Column3DStacked100; + } + + private static bool IsBarChart(ExcelChartType type) { + return type == ExcelChartType.BarClustered + || type == ExcelChartType.BarStacked + || type == ExcelChartType.BarStacked100 + || type == ExcelChartType.Bar3DClustered + || type == ExcelChartType.Bar3DStacked + || type == ExcelChartType.Bar3DStacked100; + } + + private static bool IsLineChart(ExcelChartType type) { + return type == ExcelChartType.Line + || type == ExcelChartType.LineStacked + || type == ExcelChartType.LineStacked100 + || type == ExcelChartType.Line3D; + } + + private static bool IsAreaChart(ExcelChartType type) { + return type == ExcelChartType.Area + || type == ExcelChartType.AreaStacked + || type == ExcelChartType.AreaStacked100 + || type == ExcelChartType.Area3D + || type == ExcelChartType.Area3DStacked + || type == ExcelChartType.Area3DStacked100; + } + + private static bool IsScatterChart(ExcelChartType type) { + return type == ExcelChartType.Scatter; + } + + private static bool IsRadarChart(ExcelChartType type) { + return type == ExcelChartType.Radar; + } + + private static bool IsStackedAreaChart(ExcelChartType type) { + return type == ExcelChartType.AreaStacked + || type == ExcelChartType.Area3DStacked; + } + + private static bool IsPercentStackedAreaChart(ExcelChartType type) { + return type == ExcelChartType.AreaStacked100 + || type == ExcelChartType.Area3DStacked100; + } + + private static bool IsPieChart(ExcelChartType type) { + return type == ExcelChartType.Pie + || type == ExcelChartType.Pie3D + || type == ExcelChartType.PieOfPie + || type == ExcelChartType.BarOfPie; + } + + private static bool IsDoughnutChart(ExcelChartType type) { + return type == ExcelChartType.Doughnut; + } + + private static ExcelSheet? GetWorkbookSheet(ExcelDocument document, string sheetName) { + foreach (ExcelSheet sheet in document.Sheets) { + if (string.Equals(sheet.Name, sheetName, StringComparison.Ordinal)) { + return sheet; + } + } + + return null; + } + + private static string GetExportRange(ExcelSheetReader sheet, ExcelSheet? workbookSheet, ExcelPdfSaveOptions options) { + if (options.UseWorksheetPrintAreas && workbookSheet != null) { + string? printArea = workbookSheet.GetPrintArea(); + if (!string.IsNullOrWhiteSpace(printArea)) { + return NormalizeA1Range(printArea!); + } + } + + return sheet.GetUsedRangeA1(); + } + + private static SheetExportData ReadSheetExportData(ExcelSheetReader sheet, ExcelSheet? workbookSheet, string exportRange, ExcelPdfSaveOptions options) { + string normalizedRange = NormalizeA1Range(exportRange); + RangeExportData bodyRange = ReadRangeExportData(sheet, workbookSheet, normalizedRange, options); + object?[,] values = bodyRange.Values; + ExcelCellStyleSnapshot?[,]? styles = bodyRange.Styles; + ExcelHyperlinkSnapshot?[,]? hyperlinks = bodyRange.Hyperlinks; + string?[,]? cellReferences = bodyRange.CellReferences; + MergeLayoutData? mergedCells = bodyRange.MergedCells; + ColumnLayoutData? columnWidths = bodyRange.ColumnWidths; + RowLayoutData? rowHeights = bodyRange.RowHeights; + int headerRows = options.HeaderRowCount; + if (!options.UseWorksheetPrintTitleRows || workbookSheet == null) { + return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, options); + } + + ExcelPrintTitles titles = workbookSheet.GetPrintTitles(); + if (!titles.HasRows || !A1.TryParseRange(normalizedRange, out int rangeFirstRow, out int rangeFirstColumn, out _, out int rangeLastColumn)) { + return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, options); + } + + int firstTitleRow = titles.FirstRow!.Value; + int lastTitleRow = titles.LastRow!.Value; + if (lastTitleRow < rangeFirstRow) { + string titleRange = ToA1Range(firstTitleRow, rangeFirstColumn, lastTitleRow, rangeLastColumn); + RangeExportData titleRangeData = ReadRangeExportData(sheet, workbookSheet, titleRange, options); + object?[,] prependedValues = PrependRows(titleRangeData.Values, values); + ExcelCellStyleSnapshot?[,]? prependedStyles = PrependRows(titleRangeData.Styles, styles); + ExcelHyperlinkSnapshot?[,]? prependedHyperlinks = PrependRows(titleRangeData.Hyperlinks, hyperlinks); + string?[,]? prependedCellReferences = PrependRows(titleRangeData.CellReferences, cellReferences); + MergeLayoutData? prependedMergedCells = PrependRows(titleRangeData.MergedCells, mergedCells, titleRangeData.Values.GetLength(0), values.GetLength(0), values.GetLength(1)); + RowLayoutData? prependedRowHeights = PrependRows(titleRangeData.RowHeights, rowHeights, titleRangeData.Values.GetLength(0), values.GetLength(0)); + return CreateSheetExportData( + workbookSheet, + prependedValues, + prependedStyles, + prependedHyperlinks, + prependedCellReferences, + prependedMergedCells, + columnWidths, + prependedRowHeights, + Math.Max(headerRows, titleRangeData.Values.GetLength(0)), + options); + } + + if (firstTitleRow <= rangeFirstRow && lastTitleRow >= rangeFirstRow) { + int titleRowsInsideRange = Math.Min(values.GetLength(0), lastTitleRow - rangeFirstRow + 1); + headerRows = Math.Max(headerRows, titleRowsInsideRange); + } + + return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, options); + } + + private static SheetExportData CreateSheetExportData(ExcelSheet? workbookSheet, object?[,] values, ExcelCellStyleSnapshot?[,]? styles, ExcelHyperlinkSnapshot?[,]? hyperlinks, string?[,]? cellReferences, MergeLayoutData? mergedCells, ColumnLayoutData? columnWidths, RowLayoutData? rowHeights, int headerRows, ExcelPdfSaveOptions options) { + ConditionalFillData? conditionalFills = ReadConditionalFillData( + workbookSheet, + values, + cellReferences, + options.UseWorksheetCellStyles); + + return new SheetExportData(values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, conditionalFills); + } + + private static RangeExportData ReadRangeExportData(ExcelSheetReader sheet, ExcelSheet? workbookSheet, string normalizedRange, ExcelPdfSaveOptions options) { + object?[,] rawValues = sheet.ReadRange(normalizedRange); + VisibilityLayoutData? visibility = ReadVisibilityLayoutData( + workbookSheet, + normalizedRange, + rawValues.GetLength(0), + rawValues.GetLength(1), + options.RespectWorksheetHiddenRowsAndColumns); + object?[,] values = FilterValues(rawValues, visibility); + int rowCount = values.GetLength(0); + int columnCount = values.GetLength(1); + string?[,]? cellReferences = ReadCellReferenceData( + normalizedRange, + rowCount, + columnCount, + visibility); + ExcelCellStyleSnapshot?[,]? styles = ReadCellStyleData( + workbookSheet, + normalizedRange, + rowCount, + columnCount, + options.UseWorksheetCellStyles, + visibility); + ExcelHyperlinkSnapshot?[,]? hyperlinks = ReadHyperlinkData( + workbookSheet, + normalizedRange, + rowCount, + columnCount, + options.UseWorksheetHyperlinks, + visibility); + MergeLayoutData? mergedCells = ReadMergeLayoutData( + workbookSheet, + normalizedRange, + rowCount, + columnCount, + options.UseWorksheetMergedCells, + visibility); + ColumnLayoutData? columnWidths = ReadColumnLayoutData( + workbookSheet, + normalizedRange, + columnCount, + options.UseWorksheetColumnWidths, + visibility); + RowLayoutData? rowHeights = ReadRowLayoutData( + workbookSheet, + normalizedRange, + rowCount, + options.UseWorksheetRowHeights, + visibility); + + return new RangeExportData(values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights); + } + + private static ConditionalFillData? ReadConditionalFillData(ExcelSheet? workbookSheet, object?[,] values, string?[,]? cellReferences, bool enabled) { + if (!enabled || workbookSheet == null || cellReferences == null) { + return null; + } + + IReadOnlyList rules = workbookSheet.GetConditionalFormattingRules(); + if (rules.Count == 0) { + return null; + } + + var fills = new Dictionary<(int Row, int Column), string>(); + var dataBars = new Dictionary<(int Row, int Column), ConditionalDataBarCell>(); + var icons = new Dictionary<(int Row, int Column), ConditionalIconCell>(); + foreach (ExcelConditionalFormattingInfo rule in rules + .Where(rule => string.Equals(rule.Type, "ColorScale", StringComparison.OrdinalIgnoreCase) && rule.ColorScaleColors.Count >= 2) + .OrderByDescending(rule => rule.Priority)) { + if (!TryGetRgb(rule.ColorScaleColors[0], out byte startR, out byte startG, out byte startB) || + !TryGetRgb(rule.ColorScaleColors[rule.ColorScaleColors.Count - 1], out byte endR, out byte endG, out byte endB)) { + continue; + } + + var candidates = new List<(int Row, int Column, double Value)>(); + for (int row = 0; row < values.GetLength(0); row++) { + for (int column = 0; column < values.GetLength(1); column++) { + string? cellReference = cellReferences[row, column]; + if (!string.IsNullOrWhiteSpace(cellReference) && + IsCellReferenceInReferenceList(cellReference!, rule.Range) && + TryGetConditionalNumericValue(values[row, column], out double numericValue)) { + candidates.Add((row, column, numericValue)); + } + } + } + + if (candidates.Count == 0) { + continue; + } + + double min = candidates.Min(candidate => candidate.Value); + double max = candidates.Max(candidate => candidate.Value); + foreach (var candidate in candidates) { + double ratio = max <= min ? 0.5D : Math.Max(0D, Math.Min(1D, (candidate.Value - min) / (max - min))); + fills[(candidate.Row, candidate.Column)] = InterpolateRgbHex(startR, startG, startB, endR, endG, endB, ratio); + } + } + + foreach (ExcelConditionalFormattingInfo rule in rules + .Where(rule => string.Equals(rule.Type, "DataBar", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(rule.DataBarColor)) + .OrderByDescending(rule => rule.Priority)) { + var candidates = new List<(int Row, int Column, double Value)>(); + for (int row = 0; row < values.GetLength(0); row++) { + for (int column = 0; column < values.GetLength(1); column++) { + string? cellReference = cellReferences[row, column]; + if (!string.IsNullOrWhiteSpace(cellReference) && + IsCellReferenceInReferenceList(cellReference!, rule.Range) && + TryGetConditionalNumericValue(values[row, column], out double numericValue)) { + candidates.Add((row, column, numericValue)); + } + } + } + + if (candidates.Count == 0) { + continue; + } + + double min = candidates.Min(candidate => candidate.Value); + double max = candidates.Max(candidate => candidate.Value); + foreach (var candidate in candidates) { + double ratio = max <= min ? 1D : Math.Max(0D, Math.Min(1D, (candidate.Value - min) / (max - min))); + dataBars[(candidate.Row, candidate.Column)] = new ConditionalDataBarCell(rule.DataBarColor!, ratio); + } + } + + foreach (ExcelConditionalFormattingInfo rule in rules + .Where(rule => string.Equals(rule.Type, "IconSet", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(rule.IconSet)) + .OrderByDescending(rule => rule.Priority)) { + var candidates = new List<(int Row, int Column, double Value)>(); + for (int row = 0; row < values.GetLength(0); row++) { + for (int column = 0; column < values.GetLength(1); column++) { + string? cellReference = cellReferences[row, column]; + if (!string.IsNullOrWhiteSpace(cellReference) && + IsCellReferenceInReferenceList(cellReference!, rule.Range) && + TryGetConditionalNumericValue(values[row, column], out double numericValue)) { + candidates.Add((row, column, numericValue)); + } + } + } + + if (candidates.Count == 0) { + continue; + } + + int iconCount = GetExcelIconSetCount(rule.IconSet!); + double min = candidates.Min(candidate => candidate.Value); + double max = candidates.Max(candidate => candidate.Value); + foreach (var candidate in candidates) { + int bucket = GetExcelIconSetBucket(candidate.Value, min, max, iconCount); + if (rule.IconSetReverse) { + bucket = iconCount - 1 - bucket; + } + + icons[(candidate.Row, candidate.Column)] = MapExcelIconSetCell(rule.IconSet!, bucket, iconCount); + } + } + + return fills.Count == 0 && dataBars.Count == 0 && icons.Count == 0 ? null : new ConditionalFillData(fills, dataBars, icons); + } + + private static int GetExcelIconSetCount(string iconSet) { + if (iconSet.StartsWith("Three", StringComparison.OrdinalIgnoreCase) || + iconSet.StartsWith("3", StringComparison.Ordinal)) { + return 3; + } + + if (iconSet.StartsWith("Four", StringComparison.OrdinalIgnoreCase) || + iconSet.StartsWith("4", StringComparison.Ordinal)) { + return 4; + } + + return 5; + } + + private static int GetExcelIconSetBucket(double value, double min, double max, int iconCount) { + if (iconCount <= 1 || max <= min) { + return iconCount - 1; + } + + double ratio = Math.Max(0D, Math.Min(1D, (value - min) / (max - min))); + return Math.Max(0, Math.Min(iconCount - 1, (int)Math.Floor(ratio * iconCount))); + } + + private static ConditionalIconCell MapExcelIconSetCell(string iconSet, int bucket, int iconCount) { + string normalized = iconSet.ToLowerInvariant(); + bool trafficLights = normalized.IndexOf("traffic", StringComparison.Ordinal) >= 0; + bool arrows = normalized.IndexOf("arrow", StringComparison.Ordinal) >= 0; + bool symbols = normalized.IndexOf("symbol", StringComparison.Ordinal) >= 0 || normalized.IndexOf("sign", StringComparison.Ordinal) >= 0 || normalized.IndexOf("indicator", StringComparison.Ordinal) >= 0; + + if (trafficLights) { + return new ConditionalIconCell(PdfCore.PdfCellIconKind.Circle, GetExcelIconBucketColor(bucket, iconCount)); + } + + if (arrows) { + if (bucket == 0) { + return new ConditionalIconCell(PdfCore.PdfCellIconKind.TriangleDown, PdfCore.PdfColor.FromRgb(192, 80, 77)); + } + + if (bucket >= iconCount - 1) { + return new ConditionalIconCell(PdfCore.PdfCellIconKind.TriangleUp, PdfCore.PdfColor.FromRgb(99, 155, 71)); + } + + return new ConditionalIconCell(PdfCore.PdfCellIconKind.TriangleRight, PdfCore.PdfColor.FromRgb(255, 192, 0)); + } + + if (symbols && bucket == 0) { + return new ConditionalIconCell(PdfCore.PdfCellIconKind.Diamond, PdfCore.PdfColor.FromRgb(192, 80, 77)); + } + + if (symbols && bucket >= iconCount - 1) { + return new ConditionalIconCell(PdfCore.PdfCellIconKind.Circle, PdfCore.PdfColor.FromRgb(99, 155, 71)); + } + + if (symbols) { + return new ConditionalIconCell(PdfCore.PdfCellIconKind.TriangleUp, PdfCore.PdfColor.FromRgb(255, 192, 0)); + } + + return new ConditionalIconCell(PdfCore.PdfCellIconKind.Circle, GetExcelIconBucketColor(bucket, iconCount)); + } + + private static PdfCore.PdfColor GetExcelIconBucketColor(int bucket, int iconCount) { + if (bucket <= 0) { + return PdfCore.PdfColor.FromRgb(192, 80, 77); + } + + if (bucket >= iconCount - 1) { + return PdfCore.PdfColor.FromRgb(99, 155, 71); + } + + return PdfCore.PdfColor.FromRgb(255, 192, 0); + } + + private static bool IsCellReferenceInReferenceList(string cellReference, string referenceList) { + if (string.IsNullOrWhiteSpace(referenceList)) { + return false; + } + + (int Row, int Col) cell = A1.ParseCellRef(NormalizeCellReference(cellReference)); + if (cell.Row <= 0 || cell.Col <= 0) { + return false; + } + + foreach (string rawToken in referenceList.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)) { + string token = StripSheetPrefix(rawToken).Replace("$", string.Empty); + if (A1.TryParseRange(token, out int firstRow, out int firstColumn, out int lastRow, out int lastColumn)) { + if (cell.Row >= firstRow && cell.Row <= lastRow && cell.Col >= firstColumn && cell.Col <= lastColumn) { + return true; + } + } else { + (int Row, int Col) singleCell = A1.ParseCellRef(token); + if (singleCell.Row == cell.Row && singleCell.Col == cell.Col) { + return true; + } + } + } + + return false; + } + + private static bool TryGetConditionalNumericValue(object? value, out double numericValue) { + if (value is DateTime dateTime) { + numericValue = dateTime.ToOADate(); + return true; + } + + if (value is IConvertible convertible) { + try { + numericValue = convertible.ToDouble(CultureInfo.InvariantCulture); + return !double.IsNaN(numericValue) && !double.IsInfinity(numericValue); + } catch (FormatException) { + } catch (InvalidCastException) { + } catch (OverflowException) { + } + } + + numericValue = 0D; + return false; + } + + private static bool TryGetRgb(string value, out byte r, out byte g, out byte b) { + string normalized = value.Trim().TrimStart('#'); + if (normalized.Length == 8) { + normalized = normalized.Substring(2); + } + + if (normalized.Length != 6 || + !byte.TryParse(normalized.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r) || + !byte.TryParse(normalized.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g) || + !byte.TryParse(normalized.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b)) { + r = 0; + g = 0; + b = 0; + return false; + } + + return true; + } + + private static string InterpolateRgbHex(byte startR, byte startG, byte startB, byte endR, byte endG, byte endB, double ratio) { + byte r = InterpolateByte(startR, endR, ratio); + byte g = InterpolateByte(startG, endG, ratio); + byte b = InterpolateByte(startB, endB, ratio); + return r.ToString("X2", CultureInfo.InvariantCulture) + + g.ToString("X2", CultureInfo.InvariantCulture) + + b.ToString("X2", CultureInfo.InvariantCulture); + } + + private static byte InterpolateByte(byte start, byte end, double ratio) { + return (byte)Math.Max(0, Math.Min(255, (int)Math.Round(start + ((end - start) * ratio), MidpointRounding.AwayFromZero))); + } + + private static VisibilityLayoutData? ReadVisibilityLayoutData(ExcelSheet? workbookSheet, string normalizedRange, int rowCount, int columnCount, bool enabled) { + if (!enabled || workbookSheet == null || rowCount == 0 || columnCount == 0) { + return null; + } + + if (!A1.TryParseRange(normalizedRange, out int firstRow, out int firstColumn, out _, out _)) { + return null; + } + + IReadOnlyList rowDefinitions = workbookSheet.GetRowDefinitions(); + IReadOnlyList columnDefinitions = workbookSheet.GetColumnDefinitions(); + if (!rowDefinitions.Any(row => row.Hidden) && !columnDefinitions.Any(column => column.Hidden)) { + return null; + } + + var rowOffsets = new List(rowCount); + for (int row = 0; row < rowCount; row++) { + if (!IsWorksheetRowHidden(rowDefinitions, firstRow + row)) { + rowOffsets.Add(row); + } + } + + var columnOffsets = new List(columnCount); + for (int column = 0; column < columnCount; column++) { + if (!IsWorksheetColumnHidden(columnDefinitions, firstColumn + column)) { + columnOffsets.Add(column); + } + } + + if (rowOffsets.Count == rowCount && columnOffsets.Count == columnCount) { + return null; + } + + return new VisibilityLayoutData(rowOffsets, columnOffsets, rowCount, columnCount); + } + + private static string?[,]? ReadCellReferenceData(string normalizedRange, int rowCount, int columnCount, VisibilityLayoutData? visibility = null) { + if (rowCount == 0 || columnCount == 0 || + !A1.TryParseRange(normalizedRange, out int firstRow, out int firstColumn, out _, out _)) { + return null; + } + + var references = new string?[rowCount, columnCount]; + for (int row = 0; row < rowCount; row++) { + int sourceRow = visibility?.RowOffsets[row] ?? row; + for (int column = 0; column < columnCount; column++) { + int sourceColumn = visibility?.ColumnOffsets[column] ?? column; + references[row, column] = A1.CellReference(firstRow + sourceRow, firstColumn + sourceColumn); + } + } + + return references; + } + + private static object?[,] FilterValues(object?[,] values, VisibilityLayoutData? visibility) { + if (visibility == null) { + return values; + } + + var result = new object?[visibility.RowOffsets.Count, visibility.ColumnOffsets.Count]; + for (int row = 0; row < visibility.RowOffsets.Count; row++) { + for (int column = 0; column < visibility.ColumnOffsets.Count; column++) { + result[row, column] = values[visibility.RowOffsets[row], visibility.ColumnOffsets[column]]; + } + } + + return result; + } + + private static bool IsWorksheetRowHidden(IReadOnlyList rowDefinitions, int rowIndex) { + for (int i = rowDefinitions.Count - 1; i >= 0; i--) { + ExcelRowSnapshot definition = rowDefinitions[i]; + if (definition.Index == rowIndex) { + return definition.Hidden; + } + } + + return false; + } + + private static bool IsWorksheetColumnHidden(IReadOnlyList columnDefinitions, int columnIndex) { + for (int i = columnDefinitions.Count - 1; i >= 0; i--) { + ExcelColumnSnapshot definition = columnDefinitions[i]; + if (columnIndex >= definition.StartIndex && columnIndex <= definition.EndIndex) { + return definition.Hidden; + } + } + + return false; + } + + private static ExcelCellStyleSnapshot?[,]? ReadCellStyleData(ExcelSheet? workbookSheet, string normalizedRange, int rowCount, int columnCount, bool enabled, VisibilityLayoutData? visibility = null) { + if (!enabled || workbookSheet == null || rowCount == 0 || columnCount == 0) { + return null; + } + + if (!A1.TryParseRange(normalizedRange, out int firstRow, out int firstColumn, out _, out _)) { + return null; + } + + ExcelCellStyleSnapshot?[,] styles = new ExcelCellStyleSnapshot?[rowCount, columnCount]; + bool hasAnyStyle = false; + for (int row = 0; row < rowCount; row++) { + for (int column = 0; column < columnCount; column++) { + int sourceRow = visibility?.RowOffsets[row] ?? row; + int sourceColumn = visibility?.ColumnOffsets[column] ?? column; + ExcelCellStyleSnapshot style = workbookSheet.GetCellStyle(firstRow + sourceRow, firstColumn + sourceColumn); + if (style.HasPdfVisualStyle) { + styles[row, column] = style; + hasAnyStyle = true; + } + } + } + + return hasAnyStyle ? styles : null; + } + + private static ExcelHyperlinkSnapshot?[,]? ReadHyperlinkData(ExcelSheet? workbookSheet, string normalizedRange, int rowCount, int columnCount, bool enabled, VisibilityLayoutData? visibility = null) { + if (!enabled || workbookSheet == null || rowCount == 0 || columnCount == 0) { + return null; + } + + if (!A1.TryParseRange(normalizedRange, out int firstRow, out int firstColumn, out _, out _)) { + return null; + } + + IReadOnlyDictionary worksheetHyperlinks = workbookSheet.GetHyperlinks(); + if (worksheetHyperlinks.Count == 0) { + return null; + } + + var links = new ExcelHyperlinkSnapshot?[rowCount, columnCount]; + bool hasAnyLink = false; + for (int row = 0; row < rowCount; row++) { + int sourceRow = visibility?.RowOffsets[row] ?? row; + for (int column = 0; column < columnCount; column++) { + int sourceColumn = visibility?.ColumnOffsets[column] ?? column; + string reference = A1.CellReference(firstRow + sourceRow, firstColumn + sourceColumn); + if (TryGetHyperlink(worksheetHyperlinks, reference, out ExcelHyperlinkSnapshot? hyperlink) && + IsSupportedPdfHyperlink(hyperlink)) { + links[row, column] = hyperlink; + hasAnyLink = true; + } + } + } + + return hasAnyLink ? links : null; + } + + private static bool IsSupportedPdfHyperlink(ExcelHyperlinkSnapshot hyperlink) { + if (hyperlink.IsExternal) { + return Uri.TryCreate(hyperlink.Target, UriKind.Absolute, out _); + } + + return TryParseInternalSheetName(hyperlink.Target, out _); + } + + private static bool TryGetHyperlink(IReadOnlyDictionary hyperlinks, string cellReference, out ExcelHyperlinkSnapshot hyperlink) { + if (hyperlinks.TryGetValue(cellReference, out ExcelHyperlinkSnapshot? direct)) { + hyperlink = direct; + return true; + } + + foreach (KeyValuePair entry in hyperlinks) { + (int Row, int Col) cell = A1.ParseCellRef(cellReference); + if (A1.TryParseRange(entry.Key, out int firstRow, out int firstColumn, out int lastRow, out int lastColumn) && + cell.Row >= firstRow && + cell.Row <= lastRow && + cell.Col >= firstColumn && + cell.Col <= lastColumn) { + hyperlink = entry.Value; + return true; + } + } + + hyperlink = null!; + return false; + } + + private static ColumnLayoutData? ReadColumnLayoutData(ExcelSheet? workbookSheet, string normalizedRange, int columnCount, bool enabled, VisibilityLayoutData? visibility = null) { + if (!enabled || workbookSheet == null || columnCount == 0) { + return null; + } + + if (!A1.TryParseRange(normalizedRange, out _, out int firstColumn, out _, out _)) { + return null; + } + + IReadOnlyList columnDefinitions = workbookSheet.GetColumnDefinitions(); + if (columnDefinitions.Count == 0) { + return null; + } + + var weights = new List(columnCount); + bool hasCustomWidth = false; + double totalWidth = 0D; + for (int columnOffset = 0; columnOffset < columnCount; columnOffset++) { + int sourceColumnOffset = visibility?.ColumnOffsets[columnOffset] ?? columnOffset; + int absoluteColumn = firstColumn + sourceColumnOffset; + double width = GetWorksheetColumnWidth(columnDefinitions, absoluteColumn, out bool customWidth); + weights.Add(width); + totalWidth += width; + hasCustomWidth |= customWidth; + } + + return hasCustomWidth ? new ColumnLayoutData(weights, totalWidth * 5.25D) : null; + } + + private static double GetWorksheetColumnWidth(IReadOnlyList columnDefinitions, int columnIndex, out bool customWidth) { + for (int i = columnDefinitions.Count - 1; i >= 0; i--) { + ExcelColumnSnapshot definition = columnDefinitions[i]; + if (columnIndex >= definition.StartIndex && columnIndex <= definition.EndIndex) { + customWidth = definition.CustomWidth && definition.Width.HasValue && definition.Width.Value > 0D; + return customWidth ? definition.Width!.Value : 8.43D; + } + } + + customWidth = false; + return 8.43D; + } + + private static RowLayoutData? ReadRowLayoutData(ExcelSheet? workbookSheet, string normalizedRange, int rowCount, bool enabled, VisibilityLayoutData? visibility = null) { + if (!enabled || workbookSheet == null || rowCount == 0) { + return null; + } + + if (!A1.TryParseRange(normalizedRange, out int firstRow, out _, out _, out _)) { + return null; + } + + IReadOnlyList rowDefinitions = workbookSheet.GetRowDefinitions(); + if (rowDefinitions.Count == 0) { + return null; + } + + var minHeights = new List(rowCount); + bool hasCustomHeight = false; + for (int rowOffset = 0; rowOffset < rowCount; rowOffset++) { + int sourceRowOffset = visibility?.RowOffsets[rowOffset] ?? rowOffset; + int absoluteRow = firstRow + sourceRowOffset; + double? height = GetWorksheetRowHeight(rowDefinitions, absoluteRow); + minHeights.Add(height); + hasCustomHeight |= height.HasValue; + } + + return hasCustomHeight ? new RowLayoutData(minHeights) : null; + } + + private static double? GetWorksheetRowHeight(IReadOnlyList rowDefinitions, int rowIndex) { + for (int i = rowDefinitions.Count - 1; i >= 0; i--) { + ExcelRowSnapshot definition = rowDefinitions[i]; + if (definition.Index == rowIndex) { + return definition.CustomHeight && definition.Height.HasValue && definition.Height.Value > 0D + ? definition.Height.Value + : null; + } + } + + return null; + } + + private static MergeLayoutData? ReadMergeLayoutData(ExcelSheet? workbookSheet, string normalizedRange, int rowCount, int columnCount, bool enabled, VisibilityLayoutData? visibility = null) { + if (!enabled || workbookSheet == null || rowCount == 0 || columnCount == 0) { + return null; + } + + if (!A1.TryParseRange(normalizedRange, out int firstRow, out int firstColumn, out int lastRow, out int lastColumn)) { + return null; + } + + var layout = new MergeLayoutData(rowCount, columnCount); + foreach (ExcelMergedRangeSnapshot mergedRange in workbookSheet.GetMergedRanges()) { + if (mergedRange.StartRow < firstRow || + mergedRange.StartColumn < firstColumn || + mergedRange.EndRow > lastRow || + mergedRange.EndColumn > lastColumn) { + continue; + } + + List visibleRows = MapVisibleOffsets(mergedRange.StartRow - firstRow, mergedRange.EndRow - firstRow, visibility?.RowOffsets); + List visibleColumns = MapVisibleOffsets(mergedRange.StartColumn - firstColumn, mergedRange.EndColumn - firstColumn, visibility?.ColumnOffsets); + if (visibleRows.Count == 0 || visibleColumns.Count == 0) { + continue; + } + + int relativeRow = visibleRows[0]; + int relativeColumn = visibleColumns[0]; + int rowSpan = visibleRows.Count; + int columnSpan = visibleColumns.Count; + if (rowSpan > 1 || columnSpan > 1) { + layout.SetSpan(relativeRow, relativeColumn, rowSpan, columnSpan); + } + } + + return layout.HasAny ? layout : null; + } + + private static List MapVisibleOffsets(int firstSourceOffset, int lastSourceOffset, IReadOnlyList? visibleOffsets) { + if (visibleOffsets == null) { + var all = new List(lastSourceOffset - firstSourceOffset + 1); + for (int offset = firstSourceOffset; offset <= lastSourceOffset; offset++) { + all.Add(offset); + } + + return all; + } + + var mapped = new List(); + for (int index = 0; index < visibleOffsets.Count; index++) { + int sourceOffset = visibleOffsets[index]; + if (sourceOffset >= firstSourceOffset && sourceOffset <= lastSourceOffset) { + mapped.Add(index); + } + } + + return mapped; + } + + private static object?[,] PrependRows(object?[,] topRows, object?[,] bodyRows) { + int topRowCount = topRows.GetLength(0); + int bodyRowCount = bodyRows.GetLength(0); + int columnCount = bodyRows.GetLength(1); + var result = new object?[topRowCount + bodyRowCount, columnCount]; + CopyRows(topRows, result, 0, columnCount); + CopyRows(bodyRows, result, topRowCount, columnCount); + return result; + } + + private static ExcelCellStyleSnapshot?[,]? PrependRows(ExcelCellStyleSnapshot?[,]? topRows, ExcelCellStyleSnapshot?[,]? bodyRows) { + if (topRows == null) { + return bodyRows; + } + + if (bodyRows == null) { + return topRows; + } + + int topRowCount = topRows.GetLength(0); + int bodyRowCount = bodyRows.GetLength(0); + int columnCount = bodyRows.GetLength(1); + var result = new ExcelCellStyleSnapshot?[topRowCount + bodyRowCount, columnCount]; + CopyRows(topRows, result, 0, columnCount); + CopyRows(bodyRows, result, topRowCount, columnCount); + return result; + } + + private static ExcelHyperlinkSnapshot?[,]? PrependRows(ExcelHyperlinkSnapshot?[,]? topRows, ExcelHyperlinkSnapshot?[,]? bodyRows) { + if (topRows == null) { + return bodyRows; + } + + if (bodyRows == null) { + return topRows; + } + + int topRowCount = topRows.GetLength(0); + int bodyRowCount = bodyRows.GetLength(0); + int columnCount = bodyRows.GetLength(1); + var result = new ExcelHyperlinkSnapshot?[topRowCount + bodyRowCount, columnCount]; + CopyRows(topRows, result, 0, columnCount); + CopyRows(bodyRows, result, topRowCount, columnCount); + return result; + } + + private static string?[,]? PrependRows(string?[,]? topRows, string?[,]? bodyRows) { + if (topRows == null) { + return bodyRows; + } + + if (bodyRows == null) { + return topRows; + } + + int topRowCount = topRows.GetLength(0); + int bodyRowCount = bodyRows.GetLength(0); + int columnCount = bodyRows.GetLength(1); + var result = new string?[topRowCount + bodyRowCount, columnCount]; + CopyRows(topRows, result, 0, columnCount); + CopyRows(bodyRows, result, topRowCount, columnCount); + return result; + } + + private static MergeLayoutData? PrependRows(MergeLayoutData? topRows, MergeLayoutData? bodyRows, int topRowCount, int bodyRowCount, int columnCount) { + if (topRows == null && bodyRows == null) { + return null; + } + + var result = new MergeLayoutData(topRowCount + bodyRowCount, columnCount); + topRows?.CopyTo(result, 0); + bodyRows?.CopyTo(result, topRowCount); + return result.HasAny ? result : null; + } + + private static RowLayoutData? PrependRows(RowLayoutData? topRows, RowLayoutData? bodyRows, int topRowCount, int bodyRowCount) { + if (topRows == null && bodyRows == null) { + return null; + } + + var minHeights = new List(topRowCount + bodyRowCount); + if (topRows != null) { + minHeights.AddRange(topRows.MinHeights); + } else { + for (int row = 0; row < topRowCount; row++) { + minHeights.Add(null); + } + } + + if (bodyRows != null) { + minHeights.AddRange(bodyRows.MinHeights); + } else { + for (int row = 0; row < bodyRowCount; row++) { + minHeights.Add(null); + } + } + + return minHeights.Any(height => height.HasValue) ? new RowLayoutData(minHeights) : null; + } + + private static void CopyRows(object?[,] source, object?[,] target, int targetRowOffset, int columnCount) { + int rowCount = source.GetLength(0); + int sourceColumnCount = source.GetLength(1); + for (int row = 0; row < rowCount; row++) { + for (int column = 0; column < columnCount; column++) { + target[targetRowOffset + row, column] = column < sourceColumnCount ? source[row, column] : null; + } + } + } + + private static void CopyRows(ExcelCellStyleSnapshot?[,] source, ExcelCellStyleSnapshot?[,] target, int targetRowOffset, int columnCount) { + int rowCount = source.GetLength(0); + int sourceColumnCount = source.GetLength(1); + for (int row = 0; row < rowCount; row++) { + for (int column = 0; column < columnCount; column++) { + target[targetRowOffset + row, column] = column < sourceColumnCount ? source[row, column] : null; + } + } + } + + private static void CopyRows(ExcelHyperlinkSnapshot?[,] source, ExcelHyperlinkSnapshot?[,] target, int targetRowOffset, int columnCount) { + int rowCount = source.GetLength(0); + int sourceColumnCount = source.GetLength(1); + for (int row = 0; row < rowCount; row++) { + for (int column = 0; column < columnCount; column++) { + target[targetRowOffset + row, column] = column < sourceColumnCount ? source[row, column] : null; + } + } + } + + private static void CopyRows(string?[,] source, string?[,] target, int targetRowOffset, int columnCount) { + int rowCount = source.GetLength(0); + int sourceColumnCount = source.GetLength(1); + for (int row = 0; row < rowCount; row++) { + for (int column = 0; column < columnCount; column++) { + target[targetRowOffset + row, column] = column < sourceColumnCount ? source[row, column] : null; + } + } + } + + private static string NormalizeA1Range(string range) { + string withoutSheet = StripSheetPrefix(range).Replace("$", string.Empty); + if (!A1.TryParseRange(withoutSheet, out int r1, out int c1, out int r2, out int c2)) { + throw new ArgumentException("Excel PDF export range must be a valid A1 range.", nameof(range)); + } + + return ToA1Range(r1, c1, r2, c2); + } + + private static string StripSheetPrefix(string range) { + int separator = range.LastIndexOf('!'); + return separator >= 0 ? range.Substring(separator + 1) : range; + } + + private static string ToA1Range(int firstRow, int firstColumn, int lastRow, int lastColumn) { + string start = A1.ColumnIndexToLetters(firstColumn) + firstRow.ToString(CultureInfo.InvariantCulture); + string end = A1.ColumnIndexToLetters(lastColumn) + lastRow.ToString(CultureInfo.InvariantCulture); + return start + ":" + end; + } + + private static IReadOnlyDictionary> CreateWorksheetImageMap(WorksheetPdfExportPlan plan) { + if (!plan.HasTable || plan.Images.Count == 0 || plan.ExportData.CellReferences == null) { + return new Dictionary>(StringComparer.Ordinal); + } + + var attachableCellReferences = new HashSet(StringComparer.Ordinal); + int rows = Math.Min(plan.ExportedRows, plan.ExportData.CellReferences.GetLength(0)); + int columns = plan.ExportData.CellReferences.GetLength(1); + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + if (plan.ExportData.MergedCells?.IsContinuation(row, column) == true) { + continue; + } + + string? cellReference = plan.ExportData.CellReferences[row, column]; + if (!string.IsNullOrWhiteSpace(cellReference)) { + attachableCellReferences.Add(NormalizeCellReference(cellReference!)); + } + } + } + + var imagesByCellReference = new Dictionary>(StringComparer.Ordinal); + foreach (WorksheetImageExportData image in plan.Images) { + string cellReference = NormalizeCellReference(image.CellReference); + if (!attachableCellReferences.Contains(cellReference)) { + continue; + } + + if (!imagesByCellReference.TryGetValue(cellReference, out List? images)) { + images = new List(); + imagesByCellReference[cellReference] = images; + } + + images.Add(image); + } + + var result = new Dictionary>(StringComparer.Ordinal); + foreach (KeyValuePair> item in imagesByCellReference) { + result[item.Key] = item.Value; + } + + return result; + } + + private static string NormalizeCellReference(string cellReference) { + return cellReference.Replace("$", string.Empty).ToUpperInvariant(); + } + + private static IReadOnlyList CreateTableChunks(WorksheetPdfExportPlan plan, ExcelPdfSaveOptions options, int exportedColumns) { + IReadOnlyList rowChunks = CreateTableAxisChunks( + plan.ExportedRows, + options.UseWorksheetPageBreaks ? GetManualRowBreakOffsets(plan) : new List()); + IReadOnlyList columnChunks = CreateTableAxisChunks( + exportedColumns, + options.UseWorksheetPageBreaks ? GetManualColumnBreakOffsets(plan) : new List()); + int headerRowCount = Math.Min(plan.ExportData.HeaderRowCount, plan.ExportedRows); + + var chunks = new List(rowChunks.Count * columnChunks.Count); + foreach (TableAxisChunk rowChunk in rowChunks) { + IReadOnlyList rowIndexes = CreateChunkRowIndexes(rowChunk, headerRowCount); + int chunkHeaderRows = Math.Min(headerRowCount, rowIndexes.Count); + foreach (TableAxisChunk columnChunk in columnChunks) { + chunks.Add(new TableChunk(rowIndexes, chunkHeaderRows, columnChunk.Start, columnChunk.Count)); + } + } + + return chunks; + } + + private static IReadOnlyList CreateChunkRowIndexes(TableAxisChunk rowChunk, int headerRowCount) { + var indexes = new List(rowChunk.Count + headerRowCount); + if (rowChunk.Start > 0 && headerRowCount > 0) { + for (int row = 0; row < headerRowCount; row++) { + indexes.Add(row); + } + } + + int end = rowChunk.Start + rowChunk.Count; + for (int row = rowChunk.Start; row < end; row++) { + if (row < headerRowCount && indexes.Contains(row)) { + continue; + } + + indexes.Add(row); + } + + return indexes; + } + + private static IReadOnlyList CreateTableAxisChunks(int itemCount, IReadOnlyList breakOffsets) { + if (itemCount <= 0 || breakOffsets.Count == 0) { + return new[] { new TableAxisChunk(0, itemCount) }; + } + + var chunks = new List(); + int start = 0; + foreach (int breakOffset in breakOffsets) { + if (breakOffset <= start || breakOffset >= itemCount) { + continue; + } + + chunks.Add(new TableAxisChunk(start, breakOffset - start)); + start = breakOffset; + } + + if (start < itemCount) { + chunks.Add(new TableAxisChunk(start, itemCount - start)); + } + + return chunks.Count == 0 ? new[] { new TableAxisChunk(0, itemCount) } : chunks; + } + + private static List GetManualRowBreakOffsets(WorksheetPdfExportPlan plan) { + var offsets = new SortedSet(); + string?[,]? references = plan.ExportData.CellReferences; + if (references == null) { + return offsets.ToList(); + } + + int rows = Math.Min(plan.ExportedRows, references.GetLength(0)); + foreach (int breakRow in plan.ManualRowBreaks) { + for (int row = 0; row < rows; row++) { + int originalRow = GetOriginalRowNumber(references, row); + if (originalRow > breakRow) { + if (!IsMergedCellContinuationRow(plan.ExportData.MergedCells, row, references.GetLength(1))) { + offsets.Add(row); + } + + break; + } + } + } + + return offsets.ToList(); + } + + private static List GetManualColumnBreakOffsets(WorksheetPdfExportPlan plan) { + var offsets = new SortedSet(); + string?[,]? references = plan.ExportData.CellReferences; + if (references == null) { + return offsets.ToList(); + } + + int rows = Math.Min(plan.ExportedRows, references.GetLength(0)); + int columns = references.GetLength(1); + foreach (int breakColumn in plan.ManualColumnBreaks) { + for (int column = 0; column < columns; column++) { + int originalColumn = GetOriginalColumnNumber(references, column, rows); + if (originalColumn > breakColumn) { + if (!IsMergedCellContinuationColumn(plan.ExportData.MergedCells, column, rows)) { + offsets.Add(column); + } + + break; + } + } + } + + return offsets.ToList(); + } + + private static int GetOriginalRowNumber(string?[,] references, int row) { + int columns = references.GetLength(1); + for (int column = 0; column < columns; column++) { + string? reference = references[row, column]; + if (string.IsNullOrWhiteSpace(reference)) { + continue; + } + + (int Row, int Col) cell = A1.ParseCellRef(reference!.Replace("$", string.Empty)); + if (cell.Row > 0) { + return cell.Row; + } + } + + return 0; + } + + private static int GetOriginalColumnNumber(string?[,] references, int column, int rows) { + for (int row = 0; row < rows; row++) { + string? reference = references[row, column]; + if (string.IsNullOrWhiteSpace(reference)) { + continue; + } + + (int Row, int Col) cell = A1.ParseCellRef(reference!.Replace("$", string.Empty)); + if (cell.Col > 0) { + return cell.Col; + } + } + + return 0; + } + + private static bool IsMergedCellContinuationRow(MergeLayoutData? mergedCells, int row, int columns) { + if (mergedCells == null) { + return false; + } + + for (int column = 0; column < columns; column++) { + if (mergedCells.IsContinuation(row, column)) { + return true; + } + } + + return false; + } + + private static bool IsMergedCellContinuationColumn(MergeLayoutData? mergedCells, int column, int rows) { + if (mergedCells == null) { + return false; + } + + for (int row = 0; row < rows; row++) { + if (mergedCells.IsContinuation(row, column)) { + return true; + } + } + + return false; + } + + private static IEnumerable CreatePdfRows(object?[,] values, ExcelCellStyleSnapshot?[,]? styles, ExcelHyperlinkSnapshot?[,]? hyperlinks, string?[,]? cellReferences, MergeLayoutData? mergedCells, IReadOnlyDictionary>? imagesByCellReference, IReadOnlyList rowIndexes, int startColumn, int columnCount, string emptyCellText, IReadOnlyDictionary sheetDestinations, IReadOnlyDictionary cellDestinations, string sheetName) { + int endColumn = Math.Min(values.GetLength(1), startColumn + columnCount); + for (int localRow = 0; localRow < rowIndexes.Count; localRow++) { + int row = rowIndexes[localRow]; + if (row < 0 || row >= values.GetLength(0)) { + continue; + } + + var cells = new List(columnCount); + for (int column = startColumn; column < endColumn; column++) { + if (mergedCells?.IsContinuation(row, column) == true) { + continue; + } + + ExcelCellStyleSnapshot? style = GetCellStyle(styles, row, column); + ExcelHyperlinkSnapshot? hyperlink = GetHyperlink(hyperlinks, row, column); + string text = FormatCellValue(values[row, column], style, emptyCellText); + MergeSpan? span = ClipMergeSpanToChunk(mergedCells?.GetSpan(row, column), row, rowIndexes, localRow, column, endColumn); + string? cellDestinationName = TryGetCellDestinationName(cellReferences, row, column, sheetName, cellDestinations, out string? destinationName) + ? destinationName + : null; + IReadOnlyList? cellImages = GetCellImages(imagesByCellReference, cellReferences, row, column); + cells.Add(CreatePdfCell(text, style, hyperlink, span, sheetDestinations, cellDestinations, cellDestinationName, cellImages)); + } + + yield return cells.ToArray(); + } + } + + private static MergeSpan? ClipMergeSpanToChunk(MergeSpan? span, int row, IReadOnlyList rowIndexes, int localRow, int column, int endColumn) { + if (span == null) { + return span; + } + + int clippedColumnSpan = Math.Min(span.ColumnSpan, Math.Max(1, endColumn - column)); + int contiguousRows = 1; + for (int offset = 1; offset < span.RowSpan && localRow + offset < rowIndexes.Count; offset++) { + if (rowIndexes[localRow + offset] != row + offset) { + break; + } + + contiguousRows++; + } + + int clippedRowSpan = Math.Min(span.RowSpan, contiguousRows); + if (clippedRowSpan == span.RowSpan && clippedColumnSpan == span.ColumnSpan) { + return span; + } + + return new MergeSpan(clippedRowSpan, clippedColumnSpan); + } + + private static IReadOnlyList? GetCellImages(IReadOnlyDictionary>? imagesByCellReference, string?[,]? cellReferences, int row, int column) { + if (imagesByCellReference == null || imagesByCellReference.Count == 0 || cellReferences == null || row >= cellReferences.GetLength(0) || column >= cellReferences.GetLength(1)) { + return null; + } + + string? cellReference = cellReferences[row, column]; + if (string.IsNullOrWhiteSpace(cellReference)) { + return null; + } + + return imagesByCellReference.TryGetValue(NormalizeCellReference(cellReference!), out IReadOnlyList? images) + ? images + : null; + } + + private static PdfCore.PdfTableCell CreatePdfCell(string text, ExcelCellStyleSnapshot? style, ExcelHyperlinkSnapshot? hyperlink, MergeSpan? span, IReadOnlyDictionary sheetDestinations, IReadOnlyDictionary cellDestinations, string? cellDestinationName, IReadOnlyList? cellImages) { + int rowSpan = span?.RowSpan ?? 1; + int columnSpan = span?.ColumnSpan ?? 1; + PdfCore.PdfColor? textColor = ToPdfColor(style?.FontColorHex); + string? linkUri = hyperlink?.IsExternal == true ? hyperlink.Target : null; + string? linkDestinationName = TryGetInternalHyperlinkDestinationName(hyperlink, sheetDestinations, cellDestinations, out string? destinationName) + ? destinationName + : null; + string? linkContents = linkUri == null && linkDestinationName == null ? null : text; + IReadOnlyList? pdfImages = ToPdfTableCellImages(cellImages); + IReadOnlyList runs = style != null && (style.Bold || style.Italic || style.Underline || textColor.HasValue) + ? new[] { new PdfCore.TextRun(text, bold: style.Bold, underline: style.Underline, color: textColor, italic: style.Italic) } + : new[] { PdfCore.TextRun.Normal(text) }; + + return new PdfCore.PdfTableCell( + runs, + columnSpan, + linkUri, + linkContents, + rowSpan, + images: pdfImages, + linkDestinationName: linkDestinationName, + namedDestinationName: cellDestinationName); + } + + private static IReadOnlyList? ToPdfTableCellImages(IReadOnlyList? images) { + if (images == null || images.Count == 0) { + return null; + } + + var pdfImages = new List(images.Count); + foreach (WorksheetImageExportData image in images) { + pdfImages.Add(new PdfCore.PdfTableCellImage(image.Bytes, image.WidthPoints, image.HeightPoints)); + } + + return pdfImages; + } + + private static bool TryGetCellDestinationName(string?[,]? cellReferences, int row, int column, string sheetName, IReadOnlyDictionary cellDestinations, out string? destinationName) { + destinationName = null; + if (cellReferences == null || row >= cellReferences.GetLength(0) || column >= cellReferences.GetLength(1)) { + return false; + } + + string? cellReference = cellReferences[row, column]; + return !string.IsNullOrWhiteSpace(cellReference) && + cellDestinations.TryGetValue(CreateCellDestinationKey(sheetName, cellReference!), out destinationName); + } + + private static bool TryGetInternalHyperlinkDestinationName(ExcelHyperlinkSnapshot? hyperlink, IReadOnlyDictionary sheetDestinations, IReadOnlyDictionary cellDestinations, out string? destinationName) { + destinationName = null; + if (hyperlink == null || hyperlink.IsExternal || !TryParseInternalTarget(hyperlink.Target, out string? sheetName, out string? cellReference)) { + return false; + } + + return cellDestinations.TryGetValue(CreateCellDestinationKey(sheetName!, cellReference!), out destinationName) || + sheetDestinations.TryGetValue(sheetName!, out destinationName); + } + + private static bool TryParseInternalSheetName(string? value, out string? sheetName) { + if (TryParseInternalTarget(value, out sheetName, out _)) { + return true; + } + + sheetName = null; + return false; + } + + private static bool TryParseInternalTarget(string? value, out string? sheetName, out string? cellReference) { + sheetName = null; + cellReference = null; + if (string.IsNullOrWhiteSpace(value)) { + return false; + } + + string trimmedValue = value!.Trim(); + int bangIndex = trimmedValue.LastIndexOf('!'); + if (bangIndex <= 0 || bangIndex >= trimmedValue.Length - 1) { + return false; + } + + string sheetToken = trimmedValue.Substring(0, bangIndex).Trim(); + string referenceToken = trimmedValue.Substring(bangIndex + 1).Trim().Replace("$", string.Empty); + if (sheetToken.Length == 0 || sheetToken.IndexOf('[') >= 0 || sheetToken.IndexOf(']') >= 0) { + return false; + } + + if (!TryGetTopLeftCellReference(referenceToken, out string? normalizedReference)) { + return false; + } + + string unquoted = UnquoteInternalSheetName(sheetToken); + if (unquoted.Length == 0) { + return false; + } + + sheetName = unquoted; + cellReference = normalizedReference; + return true; + } + + private static bool TryGetTopLeftCellReference(string referenceToken, out string? cellReference) { + cellReference = null; + if (string.IsNullOrWhiteSpace(referenceToken)) { + return false; + } + + string token = referenceToken.Trim(); + if (A1.TryParseRange(token, out int firstRow, out int firstColumn, out _, out _)) { + cellReference = A1.CellReference(firstRow, firstColumn); + return true; + } + + (int Row, int Col) cell = A1.ParseCellRef(token); + if (cell.Row <= 0 || cell.Col <= 0) { + return false; + } + + cellReference = A1.CellReference(cell.Row, cell.Col); + return true; + } + + private static string UnquoteInternalSheetName(string sheetToken) { + string trimmedToken = sheetToken.Trim(); + if (trimmedToken.Length >= 2 && trimmedToken[0] == '\'' && trimmedToken[trimmedToken.Length - 1] == '\'') { + return trimmedToken.Substring(1, trimmedToken.Length - 2).Replace("''", "'"); + } + + return trimmedToken; + } + + private static PdfCore.PdfTableStyle CreateTableStyle(ExcelPdfSaveOptions options, ExcelSheetPageSetup? pageSetup, IReadOnlyList rowIndexes, int headerRowCount, ExcelCellStyleSnapshot?[,]? styles, ConditionalFillData? conditionalFills, ColumnLayoutData? columnWidths, RowLayoutData? rowHeights, int columnOffset = 0, int exportedColumns = 0) { + int exportedRows = rowIndexes.Count; + int headerRows = Math.Min(headerRowCount, exportedRows); + var tableStyle = new PdfCore.PdfTableStyle { + HeaderRowCount = headerRows, + RepeatHeaderRowCount = headerRows == 0 ? null : headerRows, + CellPaddingX = 4, + CellPaddingY = 3, + HeaderFill = PdfCore.PdfColor.FromRgb(230, 238, 247), + HeaderTextColor = PdfCore.PdfColor.FromRgb(31, 78, 121), + RowStripeFill = PdfCore.PdfColor.FromRgb(248, 250, 252) + }; + + if (columnWidths != null) { + List widthWeights = columnWidths.WidthWeights.Skip(columnOffset).Take(exportedColumns).ToList(); + tableStyle.ColumnWidthWeights = widthWeights.Count == 0 ? columnWidths.WidthWeights : widthWeights; + if (IsFitToWidth(pageSetup)) { + tableStyle.MaxWidth = CalculateFitToWidthMaxWidth(options, pageSetup); + } else if (pageSetup?.Scale is uint scale && scale > 0U && scale < 100U) { + double approximateWidth = CalculateChunkApproximateWidth(columnWidths, columnOffset, exportedColumns); + tableStyle.MaxWidth = Math.Max(24D, approximateWidth * scale / 100D); + } + } + + if (rowHeights != null) { + tableStyle.RowMinHeights = rowIndexes + .Select(row => row >= 0 && row < rowHeights.MinHeights.Count ? rowHeights.MinHeights[row] : null) + .ToList(); + } + + Dictionary<(int Row, int Column), PdfCore.PdfColor>? cellFills = CreateCellFills(styles, conditionalFills, rowIndexes, columnOffset, exportedColumns); + if (cellFills != null) { + tableStyle.CellFills = cellFills; + } + + Dictionary<(int Row, int Column), PdfCore.PdfCellDataBar>? cellDataBars = CreateCellDataBars(conditionalFills, rowIndexes, columnOffset, exportedColumns); + if (cellDataBars != null) { + tableStyle.CellDataBars = cellDataBars; + } + + Dictionary<(int Row, int Column), PdfCore.PdfCellIcon>? cellIcons = CreateCellIcons(conditionalFills, rowIndexes, columnOffset, exportedColumns); + if (cellIcons != null) { + tableStyle.CellIcons = cellIcons; + tableStyle.CellPaddings = CreateIconCellPaddings(cellIcons, tableStyle.CellPaddings); + } + + Dictionary<(int Row, int Column), PdfCore.PdfColumnAlign>? cellAlignments = CreateCellAlignments(styles, rowIndexes, columnOffset, exportedColumns); + if (cellAlignments != null) { + tableStyle.CellAlignments = cellAlignments; + } + + Dictionary<(int Row, int Column), PdfCore.PdfCellVerticalAlign>? cellVerticalAlignments = CreateCellVerticalAlignments(styles, rowIndexes, columnOffset, exportedColumns); + if (cellVerticalAlignments != null) { + tableStyle.CellVerticalAlignments = cellVerticalAlignments; + } + + Dictionary<(int Row, int Column), PdfCore.PdfCellBorder>? cellBorders = CreateCellBorders(styles, rowIndexes, columnOffset, exportedColumns); + if (cellBorders != null) { + tableStyle.CellBorders = cellBorders; + } + + return tableStyle; + } + + private static double CalculateChunkApproximateWidth(ColumnLayoutData columnWidths, int columnOffset, int exportedColumns) { + if (exportedColumns <= 0 || columnOffset <= 0 && exportedColumns >= columnWidths.WidthWeights.Count) { + return columnWidths.ApproximateWidthPoints; + } + + double totalWeight = columnWidths.WidthWeights.Sum(); + double chunkWeight = columnWidths.WidthWeights.Skip(columnOffset).Take(exportedColumns).Sum(); + if (totalWeight <= 0D || chunkWeight <= 0D) { + return columnWidths.ApproximateWidthPoints; + } + + return columnWidths.ApproximateWidthPoints * chunkWeight / totalWeight; + } + + private static bool IsFitToWidth(ExcelSheetPageSetup? pageSetup) { + return pageSetup?.FitToWidth is uint fitToWidth && fitToWidth > 0U; + } + + private static double CalculateFitToWidthMaxWidth(ExcelPdfSaveOptions options, ExcelSheetPageSetup? pageSetup) { + PdfCore.PageSize pageSize = GetEffectivePageSize(options, pageSetup); + PdfCore.PageMargins margins = GetEffectiveMargins(options, pageSetup); + return Math.Max(24D, pageSize.Width - margins.Left - margins.Right); + } + + private static PdfCore.PageSize GetEffectivePageSize(ExcelPdfSaveOptions options, ExcelSheetPageSetup? pageSetup) { + PdfCore.PageSize pageSize = options.PageSize ?? PdfCore.PageSizes.Letter; + if (pageSetup?.Orientation == ExcelPageOrientation.Landscape) { + return pageSize.Landscape(); + } + + if (pageSetup?.Orientation == ExcelPageOrientation.Portrait) { + return pageSize.Portrait(); + } + + return pageSize; + } + + private static PdfCore.PageMargins GetEffectiveMargins(ExcelPdfSaveOptions options, ExcelSheetPageSetup? pageSetup) { + if (options.Margins.HasValue) { + return options.Margins.Value; + } + + if (pageSetup?.Margins != null) { + return ToPdfMargins(pageSetup.Margins); + } + + return PdfCore.PageMargins.Normal; + } + + private static ExcelCellStyleSnapshot? GetCellStyle(ExcelCellStyleSnapshot?[,]? styles, int row, int column) { + if (styles == null || row >= styles.GetLength(0) || column >= styles.GetLength(1)) { + return null; + } + + return styles[row, column]; + } + + private static ExcelHyperlinkSnapshot? GetHyperlink(ExcelHyperlinkSnapshot?[,]? hyperlinks, int row, int column) { + if (hyperlinks == null || row >= hyperlinks.GetLength(0) || column >= hyperlinks.GetLength(1)) { + return null; + } + + return hyperlinks[row, column]; + } + + private static Dictionary<(int Row, int Column), PdfCore.PdfColor>? CreateCellFills(ExcelCellStyleSnapshot?[,]? styles, ConditionalFillData? conditionalFills, IReadOnlyList rowIndexes, int columnOffset = 0, int exportedColumns = 0) { + if (styles == null && conditionalFills == null) { + return null; + } + + int columnEnd = exportedColumns > 0 ? columnOffset + exportedColumns : int.MaxValue; + Dictionary<(int Row, int Column), PdfCore.PdfColor>? fills = null; + if (styles != null) { + int columns = Math.Min(columnEnd, styles.GetLength(1)); + for (int localRow = 0; localRow < rowIndexes.Count; localRow++) { + int row = rowIndexes[localRow]; + if (row < 0 || row >= styles.GetLength(0)) { + continue; + } + + for (int column = columnOffset; column < columns; column++) { + PdfCore.PdfColor? fill = ToPdfColor(styles[row, column]?.FillColorHex); + if (fill.HasValue) { + fills ??= new Dictionary<(int Row, int Column), PdfCore.PdfColor>(); + fills[(localRow, column - columnOffset)] = fill.Value; + } + } + } + } + + if (conditionalFills != null) { + foreach (KeyValuePair<(int Row, int Column), string> conditionalFill in conditionalFills.FillColors) { + int localRow = FindLocalRowIndex(rowIndexes, conditionalFill.Key.Row); + if (localRow < 0 || + conditionalFill.Key.Column < columnOffset || + conditionalFill.Key.Column >= columnEnd) { + continue; + } + + PdfCore.PdfColor? fill = ToPdfColor(conditionalFill.Value); + if (fill.HasValue) { + fills ??= new Dictionary<(int Row, int Column), PdfCore.PdfColor>(); + fills[(localRow, conditionalFill.Key.Column - columnOffset)] = fill.Value; + } + } + } + + return fills; + } + + private static Dictionary<(int Row, int Column), PdfCore.PdfCellDataBar>? CreateCellDataBars(ConditionalFillData? conditionalFills, IReadOnlyList rowIndexes, int columnOffset = 0, int exportedColumns = 0) { + if (conditionalFills == null || conditionalFills.DataBars.Count == 0) { + return null; + } + + int columnEnd = exportedColumns > 0 ? columnOffset + exportedColumns : int.MaxValue; + Dictionary<(int Row, int Column), PdfCore.PdfCellDataBar>? dataBars = null; + foreach (KeyValuePair<(int Row, int Column), ConditionalDataBarCell> conditionalDataBar in conditionalFills.DataBars) { + int localRow = FindLocalRowIndex(rowIndexes, conditionalDataBar.Key.Row); + if (localRow < 0 || + conditionalDataBar.Key.Column < columnOffset || + conditionalDataBar.Key.Column >= columnEnd) { + continue; + } + + PdfCore.PdfColor? fill = ToPdfColor(conditionalDataBar.Value.Color); + if (fill.HasValue) { + dataBars ??= new Dictionary<(int Row, int Column), PdfCore.PdfCellDataBar>(); + dataBars[(localRow, conditionalDataBar.Key.Column - columnOffset)] = new PdfCore.PdfCellDataBar { + Color = fill.Value, + Ratio = conditionalDataBar.Value.Ratio + }; + } + } + + return dataBars; + } + + private static Dictionary<(int Row, int Column), PdfCore.PdfCellIcon>? CreateCellIcons(ConditionalFillData? conditionalFills, IReadOnlyList rowIndexes, int columnOffset = 0, int exportedColumns = 0) { + if (conditionalFills == null || conditionalFills.Icons.Count == 0) { + return null; + } + + int columnEnd = exportedColumns > 0 ? columnOffset + exportedColumns : int.MaxValue; + Dictionary<(int Row, int Column), PdfCore.PdfCellIcon>? icons = null; + foreach (KeyValuePair<(int Row, int Column), ConditionalIconCell> conditionalIcon in conditionalFills.Icons) { + int localRow = FindLocalRowIndex(rowIndexes, conditionalIcon.Key.Row); + if (localRow < 0 || + conditionalIcon.Key.Column < columnOffset || + conditionalIcon.Key.Column >= columnEnd) { + continue; + } + + icons ??= new Dictionary<(int Row, int Column), PdfCore.PdfCellIcon>(); + icons[(localRow, conditionalIcon.Key.Column - columnOffset)] = new PdfCore.PdfCellIcon { + Kind = conditionalIcon.Value.Kind, + Color = conditionalIcon.Value.Color, + Size = 8D + }; + } + + return icons; + } + + private static Dictionary<(int Row, int Column), PdfCore.PdfCellPadding> CreateIconCellPaddings(IReadOnlyDictionary<(int Row, int Column), PdfCore.PdfCellIcon> icons, Dictionary<(int Row, int Column), PdfCore.PdfCellPadding>? existingPaddings) { + var paddings = existingPaddings == null + ? new Dictionary<(int Row, int Column), PdfCore.PdfCellPadding>() + : new Dictionary<(int Row, int Column), PdfCore.PdfCellPadding>(existingPaddings); + + foreach (KeyValuePair<(int Row, int Column), PdfCore.PdfCellIcon> icon in icons) { + if (!paddings.TryGetValue(icon.Key, out PdfCore.PdfCellPadding? padding)) { + padding = new PdfCore.PdfCellPadding(); + } else { + padding = padding.Clone(); + } + + double requiredLeftPadding = icon.Value.Size + 8D; + padding.Left = Math.Max(padding.Left ?? 0D, requiredLeftPadding); + paddings[icon.Key] = padding; + } + + return paddings; + } + + private static Dictionary<(int Row, int Column), PdfCore.PdfColumnAlign>? CreateCellAlignments(ExcelCellStyleSnapshot?[,]? styles, IReadOnlyList rowIndexes, int columnOffset = 0, int exportedColumns = 0) { + if (styles == null) { + return null; + } + + int columnEnd = exportedColumns > 0 ? columnOffset + exportedColumns : styles.GetLength(1); + int columns = Math.Min(columnEnd, styles.GetLength(1)); + Dictionary<(int Row, int Column), PdfCore.PdfColumnAlign>? alignments = null; + for (int localRow = 0; localRow < rowIndexes.Count; localRow++) { + int row = rowIndexes[localRow]; + if (row < 0 || row >= styles.GetLength(0)) { + continue; + } + + for (int column = columnOffset; column < columns; column++) { + PdfCore.PdfColumnAlign? alignment = ToPdfHorizontalAlignment(styles[row, column]?.HorizontalAlignment); + if (alignment.HasValue) { + alignments ??= new Dictionary<(int Row, int Column), PdfCore.PdfColumnAlign>(); + alignments[(localRow, column - columnOffset)] = alignment.Value; + } + } + } + + return alignments; + } + + private static Dictionary<(int Row, int Column), PdfCore.PdfCellVerticalAlign>? CreateCellVerticalAlignments(ExcelCellStyleSnapshot?[,]? styles, IReadOnlyList rowIndexes, int columnOffset = 0, int exportedColumns = 0) { + if (styles == null) { + return null; + } + + int columnEnd = exportedColumns > 0 ? columnOffset + exportedColumns : styles.GetLength(1); + int columns = Math.Min(columnEnd, styles.GetLength(1)); + Dictionary<(int Row, int Column), PdfCore.PdfCellVerticalAlign>? alignments = null; + for (int localRow = 0; localRow < rowIndexes.Count; localRow++) { + int row = rowIndexes[localRow]; + if (row < 0 || row >= styles.GetLength(0)) { + continue; + } + + for (int column = columnOffset; column < columns; column++) { + PdfCore.PdfCellVerticalAlign? alignment = ToPdfVerticalAlignment(styles[row, column]?.VerticalAlignment); + if (alignment.HasValue) { + alignments ??= new Dictionary<(int Row, int Column), PdfCore.PdfCellVerticalAlign>(); + alignments[(localRow, column - columnOffset)] = alignment.Value; + } + } + } + + return alignments; + } + + private static Dictionary<(int Row, int Column), PdfCore.PdfCellBorder>? CreateCellBorders(ExcelCellStyleSnapshot?[,]? styles, IReadOnlyList rowIndexes, int columnOffset = 0, int exportedColumns = 0) { + if (styles == null) { + return null; + } + + int columnEnd = exportedColumns > 0 ? columnOffset + exportedColumns : styles.GetLength(1); + int columns = Math.Min(columnEnd, styles.GetLength(1)); + Dictionary<(int Row, int Column), PdfCore.PdfCellBorder>? borders = null; + for (int localRow = 0; localRow < rowIndexes.Count; localRow++) { + int row = rowIndexes[localRow]; + if (row < 0 || row >= styles.GetLength(0)) { + continue; + } + + for (int column = columnOffset; column < columns; column++) { + PdfCore.PdfCellBorder? border = ToPdfCellBorder(styles[row, column]?.Border); + if (border != null) { + borders ??= new Dictionary<(int Row, int Column), PdfCore.PdfCellBorder>(); + borders[(localRow, column - columnOffset)] = border; + } + } + } + + return borders; + } + + private static int FindLocalRowIndex(IReadOnlyList rowIndexes, int row) { + for (int index = 0; index < rowIndexes.Count; index++) { + if (rowIndexes[index] == row) { + return index; + } + } + + return -1; + } + + private static PdfCore.PdfColumnAlign? ToPdfHorizontalAlignment(string? alignment) { + if (string.IsNullOrWhiteSpace(alignment)) { + return null; + } + + switch (alignment!.Trim().ToLowerInvariant()) { + case "left": + return PdfCore.PdfColumnAlign.Left; + case "center": + case "centercontinuous": + return PdfCore.PdfColumnAlign.Center; + case "right": + return PdfCore.PdfColumnAlign.Right; + default: + return null; + } + } + + private static PdfCore.PdfCellVerticalAlign? ToPdfVerticalAlignment(string? alignment) { + if (string.IsNullOrWhiteSpace(alignment)) { + return null; + } + + switch (alignment!.Trim().ToLowerInvariant()) { + case "top": + return PdfCore.PdfCellVerticalAlign.Top; + case "center": + return PdfCore.PdfCellVerticalAlign.Middle; + case "bottom": + return PdfCore.PdfCellVerticalAlign.Bottom; + default: + return null; + } + } + + private static PdfCore.PdfCellBorder? ToPdfCellBorder(ExcelCellBorderSnapshot? border) { + if (border == null) { + return null; + } + + PdfCore.PdfCellBorderSide? left = ToPdfCellBorderSide(border.Left); + PdfCore.PdfCellBorderSide? right = ToPdfCellBorderSide(border.Right); + PdfCore.PdfCellBorderSide? top = ToPdfCellBorderSide(border.Top); + PdfCore.PdfCellBorderSide? bottom = ToPdfCellBorderSide(border.Bottom); + PdfCore.PdfCellBorderSide? diagonal = ToPdfCellBorderSide(border.Diagonal); + bool hasDiagonalUp = border.DiagonalUp && diagonal != null; + bool hasDiagonalDown = border.DiagonalDown && diagonal != null; + if (left == null && right == null && top == null && bottom == null && !hasDiagonalUp && !hasDiagonalDown) { + return null; + } + + return new PdfCore.PdfCellBorder { + Color = null, + TopBorder = top, + RightBorder = right, + BottomBorder = bottom, + LeftBorder = left, + DiagonalUp = hasDiagonalUp, + DiagonalDown = hasDiagonalDown, + DiagonalUpBorder = hasDiagonalUp ? diagonal : null, + DiagonalDownBorder = hasDiagonalDown ? diagonal : null + }; + } + + private static PdfCore.PdfCellBorderSide? ToPdfCellBorderSide(ExcelBorderSideSnapshot? side) { + if (side == null) { + return null; + } + + double width = ToPdfBorderWidth(side.Style); + if (width <= 0) { + return null; + } + + return new PdfCore.PdfCellBorderSide { + Color = ToPdfColor(side.ColorArgb) ?? PdfCore.PdfColor.FromRgb(0, 0, 0), + Width = width, + DashStyle = ToPdfBorderDashStyle(side.Style), + LineStyle = ToPdfBorderLineStyle(side.Style) + }; + } + + private static double ToPdfBorderWidth(string? style) { + if (string.IsNullOrWhiteSpace(style)) { + return 0D; + } + + switch (style!.Trim().ToLowerInvariant()) { + case "none": + return 0D; + case "hair": + return 0.25D; + case "medium": + case "mediumdashdot": + case "mediumdashdotdot": + case "mediumdashed": + return 1.25D; + case "thick": + case "double": + return 2D; + default: + return 0.5D; + } + } + + private static OfficeIMO.Drawing.OfficeStrokeDashStyle ToPdfBorderDashStyle(string? style) { + if (string.IsNullOrWhiteSpace(style)) { + return OfficeIMO.Drawing.OfficeStrokeDashStyle.Solid; + } + + switch (style!.Trim().ToLowerInvariant()) { + case "dashed": + case "mediumdashed": + return OfficeIMO.Drawing.OfficeStrokeDashStyle.Dash; + case "dotted": + return OfficeIMO.Drawing.OfficeStrokeDashStyle.Dot; + case "dashdot": + case "dashdotdot": + case "mediumdashdot": + case "mediumdashdotdot": + case "slantdashdot": + return OfficeIMO.Drawing.OfficeStrokeDashStyle.DashDot; + default: + return OfficeIMO.Drawing.OfficeStrokeDashStyle.Solid; + } + } + + private static PdfCore.PdfCellBorderLineStyle ToPdfBorderLineStyle(string? style) { + string? normalized = style?.Trim(); + return !string.IsNullOrWhiteSpace(normalized) && + string.Equals(normalized, "double", StringComparison.OrdinalIgnoreCase) + ? PdfCore.PdfCellBorderLineStyle.TwoLine + : PdfCore.PdfCellBorderLineStyle.Standard; + } + + private static PdfCore.PdfColor? ToPdfColor(string? hex) { + if (string.IsNullOrWhiteSpace(hex)) { + return null; + } + + string value = hex!.Trim(); + if (value.StartsWith("#", StringComparison.Ordinal)) { + value = value.Substring(1); + } + + if (value.Length == 8) { + value = value.Substring(2); + } + + if (value.Length != 6 || + !byte.TryParse(value.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte r) || + !byte.TryParse(value.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte g) || + !byte.TryParse(value.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte b)) { + return null; + } + + return PdfCore.PdfColor.FromRgb(r, g, b); + } + + private static string FormatCellValue(object? value, ExcelCellStyleSnapshot? style, string emptyCellText) { + if (value == null) { + return emptyCellText; + } + + string? formatCode = style?.NumberFormatCode; + if (!string.IsNullOrWhiteSpace(formatCode)) { + string? formatted = TryFormatCellValue(value, style!, formatCode!); + if (formatted != null) { + return formatted; + } + } + + if (value is IFormattable formattable) { + return formattable.ToString(null, CultureInfo.InvariantCulture) ?? emptyCellText; + } + + return value.ToString() ?? emptyCellText; + } + + private static string? TryFormatCellValue(object value, ExcelCellStyleSnapshot style, string formatCode) { + string normalized = GetPrimaryNumberFormatSection(formatCode).Trim(); + if (normalized.Length == 0 || + string.Equals(normalized, "General", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, "@", StringComparison.Ordinal)) { + return null; + } + + if (value is DateTime dateValue || style.IsDateLike) { + DateTime date = value is DateTime directDate + ? directDate + : TryGetDouble(value, out double oaDate) ? DateTime.FromOADate(oaDate) : default; + if (date != default) { + return date.ToString(ToDotNetDateTimeFormat(normalized), CultureInfo.InvariantCulture); + } + } + + if (!TryGetDouble(value, out double number)) { + return null; + } + + if (normalized.IndexOf('%') >= 0) { + int decimals = CountDecimalPlaces(normalized); + string numeric = (number * 100D).ToString(decimals > 0 ? "N" + decimals.ToString(CultureInfo.InvariantCulture) : "N0", CultureInfo.InvariantCulture); + return numeric + "%"; + } + + string? prefix = ExtractQuotedLiteralPrefix(normalized); + bool useGrouping = normalized.IndexOf(',') >= 0; + int decimalPlaces = CountDecimalPlaces(normalized); + string numberFormat = (useGrouping ? "N" : "F") + decimalPlaces.ToString(CultureInfo.InvariantCulture); + return (prefix ?? string.Empty) + number.ToString(numberFormat, CultureInfo.InvariantCulture); + } + + private static bool TryGetDouble(object value, out double number) { + switch (value) { + case double doubleValue: + number = doubleValue; + return true; + case float floatValue: + number = floatValue; + return true; + case decimal decimalValue: + number = (double)decimalValue; + return true; + case int intValue: + number = intValue; + return true; + case long longValue: + number = longValue; + return true; + case short shortValue: + number = shortValue; + return true; + case byte byteValue: + number = byteValue; + return true; + default: + return double.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out number); + } + } + + private static string GetPrimaryNumberFormatSection(string formatCode) { + int separator = formatCode.IndexOf(';'); + return separator >= 0 ? formatCode.Substring(0, separator) : formatCode; + } + + private static int CountDecimalPlaces(string formatCode) { + int decimalIndex = formatCode.IndexOf('.'); + if (decimalIndex < 0) { + return 0; + } + + int count = 0; + for (int i = decimalIndex + 1; i < formatCode.Length; i++) { + char ch = formatCode[i]; + if (ch == '0' || ch == '#') { + count++; + continue; + } + + break; + } + + return count; + } + + private static string? ExtractQuotedLiteralPrefix(string formatCode) { + int quoteStart = formatCode.IndexOf('"'); + if (quoteStart < 0) { + return null; + } + + int quoteEnd = formatCode.IndexOf('"', quoteStart + 1); + if (quoteEnd <= quoteStart + 1) { + return null; + } + + string literal = formatCode.Substring(quoteStart + 1, quoteEnd - quoteStart - 1); + return literal.Length == 0 ? null : literal; + } + + private static string ToDotNetDateTimeFormat(string excelFormat) { + string format = StripExcelBracketAndColorTokens(excelFormat); + format = ReplaceExcelDateTokens(format); + format = ReplaceIgnoreCase(format, "AM/PM", "tt"); + format = ReplaceIgnoreCase(format, "A/P", "tt"); + return format; + } + + private static string ReplaceIgnoreCase(string value, string oldValue, string newValue) { + return value + .Replace(oldValue, newValue) + .Replace(oldValue.ToLowerInvariant(), newValue) + .Replace(oldValue.ToUpperInvariant(), newValue); + } + + private static string StripExcelBracketAndColorTokens(string format) { + var builder = new System.Text.StringBuilder(format.Length); + for (int i = 0; i < format.Length; i++) { + char ch = format[i]; + if (ch == '[') { + int close = format.IndexOf(']', i + 1); + if (close >= 0) { + string token = format.Substring(i + 1, close - i - 1); + if (token.All(c => c == 'h' || c == 'H' || c == 'm' || c == 'M' || c == 's' || c == 'S')) { + builder.Append(token); + } + + i = close; + continue; + } + } + + if (ch == '\\' || ch == '_') { + if (i + 1 < format.Length) { + builder.Append(format[i + 1]); + i++; + } + continue; + } + + builder.Append(ch); + } + + return builder.ToString(); + } + + private static string ReplaceExcelDateTokens(string format) { + var builder = new System.Text.StringBuilder(format.Length); + for (int i = 0; i < format.Length;) { + char ch = format[i]; + if (ch == '"') { + int end = format.IndexOf('"', i + 1); + if (end < 0) { + break; + } + + builder.Append('\'').Append(format.Substring(i + 1, end - i - 1).Replace("'", "\\'")).Append('\''); + i = end + 1; + continue; + } + + if (!IsExcelDateFormatLetter(ch)) { + builder.Append(ch); + i++; + continue; + } + + int start = i; + while (i < format.Length && char.ToLowerInvariant(format[i]) == char.ToLowerInvariant(ch)) { + i++; + } + + string token = format.Substring(start, i - start); + builder.Append(ConvertExcelDateToken(token, builder)); + } + + return builder.ToString(); + } + + private static bool IsExcelDateFormatLetter(char ch) { + switch (char.ToLowerInvariant(ch)) { + case 'y': + case 'm': + case 'd': + case 'h': + case 's': + return true; + default: + return false; + } + } + + private static string ConvertExcelDateToken(string token, System.Text.StringBuilder output) { + char lower = char.ToLowerInvariant(token[0]); + switch (lower) { + case 'y': + return token.Length <= 2 ? "yy" : "yyyy"; + case 'd': + return token.Length <= 1 ? "d" : token.Length == 2 ? "dd" : token.Length == 3 ? "ddd" : "dddd"; + case 'h': + return token.Length <= 1 ? "h" : "hh"; + case 's': + return token.Length <= 1 ? "s" : "ss"; + case 'm': + bool timeMinute = PreviousNonSpace(output) == ':'; + if (timeMinute) { + return token.Length <= 1 ? "m" : "mm"; + } + + return token.Length <= 1 ? "M" : token.Length == 2 ? "MM" : token.Length == 3 ? "MMM" : "MMMM"; + default: + return token; + } + } + + private static char PreviousNonSpace(System.Text.StringBuilder builder) { + for (int i = builder.Length - 1; i >= 0; i--) { + if (!char.IsWhiteSpace(builder[i])) { + return builder[i]; + } + } + + return '\0'; + } + + private sealed class WorksheetPdfExportPlan { + public WorksheetPdfExportPlan(string sheetName, ExcelSheetPageSetup? pageSetup, ExcelSheet.HeaderFooterSnapshot? headerFooter, SheetExportData exportData, IReadOnlyList images, IReadOnlyList charts, bool hasTable, int exportedRows, IReadOnlyList manualRowBreaks, IReadOnlyList manualColumnBreaks, string bookmarkName) { + SheetName = sheetName; + PageSetup = pageSetup; + HeaderFooter = headerFooter; + ExportData = exportData; + Images = images; + Charts = charts; + HasTable = hasTable; + ExportedRows = exportedRows; + ManualRowBreaks = manualRowBreaks; + ManualColumnBreaks = manualColumnBreaks; + BookmarkName = bookmarkName; + } + + public string SheetName { get; } + public ExcelSheetPageSetup? PageSetup { get; } + public ExcelSheet.HeaderFooterSnapshot? HeaderFooter { get; } + public SheetExportData ExportData { get; } + public IReadOnlyList Images { get; } + public IReadOnlyList Charts { get; } + public bool HasTable { get; } + public int ExportedRows { get; } + public IReadOnlyList ManualRowBreaks { get; } + public IReadOnlyList ManualColumnBreaks { get; } + public string BookmarkName { get; } + } + + private sealed class SheetExportData { + public SheetExportData(object?[,] values, ExcelCellStyleSnapshot?[,]? styles, ExcelHyperlinkSnapshot?[,]? hyperlinks, string?[,]? cellReferences, MergeLayoutData? mergedCells, ColumnLayoutData? columnWidths, RowLayoutData? rowHeights, int headerRowCount, ConditionalFillData? conditionalFills = null) { + Values = values; + Styles = styles; + Hyperlinks = hyperlinks; + CellReferences = cellReferences; + MergedCells = mergedCells; + ColumnWidths = columnWidths; + RowHeights = rowHeights; + HeaderRowCount = headerRowCount; + ConditionalFills = conditionalFills; + } + + public object?[,] Values { get; } + public ExcelCellStyleSnapshot?[,]? Styles { get; } + public ExcelHyperlinkSnapshot?[,]? Hyperlinks { get; } + public string?[,]? CellReferences { get; } + public MergeLayoutData? MergedCells { get; } + public ColumnLayoutData? ColumnWidths { get; } + public RowLayoutData? RowHeights { get; } + public int HeaderRowCount { get; } + public ConditionalFillData? ConditionalFills { get; } + } + + private sealed class ConditionalFillData { + public ConditionalFillData(IReadOnlyDictionary<(int Row, int Column), string> fillColors, IReadOnlyDictionary<(int Row, int Column), ConditionalDataBarCell> dataBars, IReadOnlyDictionary<(int Row, int Column), ConditionalIconCell> icons) { + FillColors = fillColors; + DataBars = dataBars; + Icons = icons; + } + + public IReadOnlyDictionary<(int Row, int Column), string> FillColors { get; } + public IReadOnlyDictionary<(int Row, int Column), ConditionalDataBarCell> DataBars { get; } + public IReadOnlyDictionary<(int Row, int Column), ConditionalIconCell> Icons { get; } + } + + private sealed class ConditionalDataBarCell { + public ConditionalDataBarCell(string color, double ratio) { + Color = color; + Ratio = ratio; + } + + public string Color { get; } + public double Ratio { get; } + } + + private sealed class ConditionalIconCell { + public ConditionalIconCell(PdfCore.PdfCellIconKind kind, PdfCore.PdfColor color) { + Kind = kind; + Color = color; + } + + public PdfCore.PdfCellIconKind Kind { get; } + public PdfCore.PdfColor Color { get; } + } + + private sealed class WorksheetImageExportData { + public WorksheetImageExportData(byte[] bytes, double widthPoints, double heightPoints, string cellReference) { + Bytes = bytes; + WidthPoints = widthPoints; + HeightPoints = heightPoints; + CellReference = cellReference; + } + + public byte[] Bytes { get; } + public double WidthPoints { get; } + public double HeightPoints { get; } + public string CellReference { get; } + } + + private sealed class WorksheetChartExportData { + public WorksheetChartExportData(ExcelChartSnapshot snapshot) { + Snapshot = snapshot; + } + + public ExcelChartSnapshot Snapshot { get; } + } + + private sealed class RangeExportData { + public RangeExportData(object?[,] values, ExcelCellStyleSnapshot?[,]? styles, ExcelHyperlinkSnapshot?[,]? hyperlinks, string?[,]? cellReferences, MergeLayoutData? mergedCells, ColumnLayoutData? columnWidths, RowLayoutData? rowHeights) { + Values = values; + Styles = styles; + Hyperlinks = hyperlinks; + CellReferences = cellReferences; + MergedCells = mergedCells; + ColumnWidths = columnWidths; + RowHeights = rowHeights; + } + + public object?[,] Values { get; } + public ExcelCellStyleSnapshot?[,]? Styles { get; } + public ExcelHyperlinkSnapshot?[,]? Hyperlinks { get; } + public string?[,]? CellReferences { get; } + public MergeLayoutData? MergedCells { get; } + public ColumnLayoutData? ColumnWidths { get; } + public RowLayoutData? RowHeights { get; } + } + + private sealed class VisibilityLayoutData { + public VisibilityLayoutData(List rowOffsets, List columnOffsets, int originalRowCount, int originalColumnCount) { + RowOffsets = rowOffsets; + ColumnOffsets = columnOffsets; + OriginalRowCount = originalRowCount; + OriginalColumnCount = originalColumnCount; + } + + public List RowOffsets { get; } + public List ColumnOffsets { get; } + public int OriginalRowCount { get; } + public int OriginalColumnCount { get; } + } + + private sealed class ColumnLayoutData { + public ColumnLayoutData(List widthWeights, double approximateWidthPoints) { + WidthWeights = widthWeights; + ApproximateWidthPoints = approximateWidthPoints; + } + + public List WidthWeights { get; } + public double ApproximateWidthPoints { get; } + } + + private sealed class RowLayoutData { + public RowLayoutData(List minHeights) { + MinHeights = minHeights; + } + + public List MinHeights { get; } + } + + private sealed class TableAxisChunk { + public TableAxisChunk(int start, int count) { + Start = start; + Count = count; + } + + public int Start { get; } + public int Count { get; } + } + + private sealed class TableChunk { + public TableChunk(IReadOnlyList rowIndexes, int headerRowCount, int startColumn, int columnCount) { + RowIndexes = rowIndexes; + HeaderRowCount = headerRowCount; + StartColumn = startColumn; + ColumnCount = columnCount; + } + + public IReadOnlyList RowIndexes { get; } + public int HeaderRowCount { get; } + public int StartColumn { get; } + public int ColumnCount { get; } + } + + private sealed class MergeLayoutData { + private readonly MergeSpan?[,] _spans; + private readonly bool[,] _continuations; + + public MergeLayoutData(int rowCount, int columnCount) { + _spans = new MergeSpan?[rowCount, columnCount]; + _continuations = new bool[rowCount, columnCount]; + } + + public bool HasAny { get; private set; } + + public void SetSpan(int row, int column, int rowSpan, int columnSpan) { + if (row < 0 || column < 0 || row >= _spans.GetLength(0) || column >= _spans.GetLength(1)) { + return; + } + + rowSpan = Math.Min(rowSpan, _spans.GetLength(0) - row); + columnSpan = Math.Min(columnSpan, _spans.GetLength(1) - column); + if (rowSpan <= 1 && columnSpan <= 1) { + return; + } + + _spans[row, column] = new MergeSpan(rowSpan, columnSpan); + for (int r = row; r < row + rowSpan; r++) { + for (int c = column; c < column + columnSpan; c++) { + if (r != row || c != column) { + _continuations[r, c] = true; + } + } + } + + HasAny = true; + } + + public MergeSpan? GetSpan(int row, int column) => + row >= 0 && column >= 0 && row < _spans.GetLength(0) && column < _spans.GetLength(1) + ? _spans[row, column] + : null; + + public bool IsContinuation(int row, int column) => + row >= 0 && column >= 0 && row < _continuations.GetLength(0) && column < _continuations.GetLength(1) && _continuations[row, column]; + + public void CopyTo(MergeLayoutData target, int rowOffset) { + for (int row = 0; row < _spans.GetLength(0); row++) { + for (int column = 0; column < _spans.GetLength(1); column++) { + MergeSpan? span = _spans[row, column]; + if (span != null) { + target.SetSpan(row + rowOffset, column, span.RowSpan, span.ColumnSpan); + } + } + } + } + } + + private sealed class MergeSpan { + public MergeSpan(int rowSpan, int columnSpan) { + RowSpan = rowSpan; + ColumnSpan = columnSpan; + } + + public int RowSpan { get; } + public int ColumnSpan { get; } + } + } +} diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfExportWarning.cs b/OfficeIMO.Excel.Pdf/ExcelPdfExportWarning.cs new file mode 100644 index 000000000..86f16d9ca --- /dev/null +++ b/OfficeIMO.Excel.Pdf/ExcelPdfExportWarning.cs @@ -0,0 +1,33 @@ +namespace OfficeIMO.Excel.Pdf { + /// + /// Describes workbook content that could not be mapped faithfully during Excel-to-PDF export. + /// + public sealed class ExcelPdfExportWarning { + /// Creates a warning for content skipped or simplified during export. + public ExcelPdfExportWarning(string sheetName, string feature, string message) { + SheetName = sheetName ?? string.Empty; + Feature = feature ?? string.Empty; + Message = message ?? string.Empty; + } + + /// Worksheet name associated with the warning. + public string SheetName { get; } + + /// Feature area associated with the warning. + public string Feature { get; } + + /// Human-readable warning message. + public string Message { get; } + + /// + public override string ToString() { + if (string.IsNullOrWhiteSpace(SheetName)) { + return string.IsNullOrWhiteSpace(Feature) ? Message : Feature + ": " + Message; + } + + return string.IsNullOrWhiteSpace(Feature) + ? SheetName + ": " + Message + : SheetName + " [" + Feature + "]: " + Message; + } + } +} diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfSaveOptions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfSaveOptions.cs new file mode 100644 index 000000000..29904f819 --- /dev/null +++ b/OfficeIMO.Excel.Pdf/ExcelPdfSaveOptions.cs @@ -0,0 +1,150 @@ +using PdfCore = OfficeIMO.Pdf; + +namespace OfficeIMO.Excel.Pdf { + /// + /// Options controlling first-party OfficeIMO Excel-to-PDF export. + /// + public sealed class ExcelPdfSaveOptions { + private int _headerRowCount = 1; + private int? _maxRowsPerSheet; + + /// + /// Warnings populated when workbook content cannot be mapped faithfully. + /// The collection is cleared at the start of each export. + /// + public List Warnings { get; } = new List(); + + /// + /// Optional first-party page size in PDF points. + /// + public PdfCore.PageSize? PageSize { get; set; } + + /// + /// Optional first-party page margins in PDF points. + /// + public PdfCore.PageMargins? Margins { get; set; } + + /// + /// Optional workbook sheet names to export. When null or empty, all workbook sheets are exported in workbook order. + /// + public IReadOnlyList? SheetNames { get; set; } + + /// + /// When true, hidden workbook worksheets are omitted from the default all-sheets export. Explicit SheetNames still export requested sheets. Defaults to true. + /// + public bool RespectWorkbookSheetVisibility { get; set; } = true; + + /// + /// When true, worksheet print areas are used instead of the used range when configured. Defaults to true. + /// + public bool UseWorksheetPrintAreas { get; set; } = true; + + /// + /// When true, worksheet orientation and margins are applied when explicit PDF options do not replace them. Defaults to true. + /// + public bool UseWorksheetPageSetup { get; set; } = true; + + /// + /// When true, worksheet repeated print-title rows are exported as PDF table header rows. Defaults to true. + /// + public bool UseWorksheetPrintTitleRows { get; set; } = true; + + /// + /// When true, manual worksheet row page breaks split the exported PDF table across pages. Defaults to true. + /// + public bool UseWorksheetPageBreaks { get; set; } = true; + + /// + /// When true, simple worksheet header/footer text zones are exported to PDF page header/footer zones. Defaults to true. + /// + public bool UseWorksheetHeadersAndFooters { get; set; } = true; + + /// + /// Optional local date/time provider used when expanding Excel header/footer &D and &T fields. + /// + public Func? HeaderFooterDateTimeProvider { get; set; } + + /// + /// When true, supported worksheet header/footer images are exported to PDF header/footer image zones. Defaults to true. + /// + public bool UseWorksheetHeaderFooterImages { get; set; } = true; + + /// + /// When true, simple worksheet cell number formats, font emphasis, colors, alignment, and borders are exported to PDF table cells. Defaults to true. + /// + public bool UseWorksheetCellStyles { get; set; } = true; + + /// + /// When true, external worksheet cell hyperlinks and internal workbook links to exported sheets are exported as PDF table-cell link annotations. Defaults to true. + /// + public bool UseWorksheetHyperlinks { get; set; } = true; + + /// + /// When true, supported worksheet drawing images are exported as PDF flow images in anchor order. Defaults to true. + /// + public bool UseWorksheetImages { get; set; } = true; + + /// + /// When true, supported worksheet charts are exported as first-party PDF drawing snapshots. Defaults to true. + /// + public bool UseWorksheetCharts { get; set; } = true; + + /// + /// When true, worksheet merged cells are exported as PDF table column and row spans. Defaults to true. + /// + public bool UseWorksheetMergedCells { get; set; } = true; + + /// + /// When true, explicit worksheet column widths influence PDF table column proportions. Defaults to true. + /// + public bool UseWorksheetColumnWidths { get; set; } = true; + + /// + /// When true, explicit worksheet row heights influence PDF table row heights. Defaults to true. + /// + public bool UseWorksheetRowHeights { get; set; } = true; + + /// + /// When true, hidden worksheet rows and columns are omitted from the exported PDF table. Defaults to true. + /// + public bool RespectWorksheetHiddenRowsAndColumns { get; set; } = true; + + /// + /// Determines whether exported sheets start with the worksheet name as a PDF heading. Defaults to true. + /// + public bool IncludeSheetHeadings { get; set; } = true; + + /// + /// Number of leading worksheet rows styled as table headers. Defaults to one row. + /// + public int HeaderRowCount { + get => _headerRowCount; + set { + if (value < 0) { + throw new ArgumentOutOfRangeException(nameof(value), "Header row count cannot be negative."); + } + + _headerRowCount = value; + } + } + + /// + /// Optional maximum number of used-range rows exported from each sheet. + /// + public int? MaxRowsPerSheet { + get => _maxRowsPerSheet; + set { + if (value.HasValue && value.Value <= 0) { + throw new ArgumentOutOfRangeException(nameof(value), "Maximum exported row count must be positive."); + } + + _maxRowsPerSheet = value; + } + } + + /// + /// Text used for empty worksheet cells in the exported PDF table. Defaults to an empty string. + /// + public string EmptyCellText { get; set; } = string.Empty; + } +} diff --git a/OfficeIMO.Excel.Pdf/OfficeIMO.Excel.Pdf.csproj b/OfficeIMO.Excel.Pdf/OfficeIMO.Excel.Pdf.csproj new file mode 100644 index 000000000..1ac72cc36 --- /dev/null +++ b/OfficeIMO.Excel.Pdf/OfficeIMO.Excel.Pdf.csproj @@ -0,0 +1,51 @@ + + + PDF converter for OfficeIMO.Excel - Export Excel workbooks to PDF using the first-party OfficeIMO.Pdf engine. + OfficeIMO.Excel.Pdf + OfficeIMO.Excel.Pdf + OfficeIMO.Excel.Pdf + 1.0.0 + netstandard2.0;net8.0;net10.0 + $(TargetFrameworks);net472 + false + Evotec + Przemyslaw Klys + pdf;openxml;office;xlsx;excel;officeimo-pdf + https://github.com/EvotecIT/OfficeIMO + MIT + https://github.com/EvotecIT/OfficeIMO + git + OfficeIMO.png + README.md + true + Latest + enable + + + + + True + \ + + + True + README.md + + + + + + + + + + + + + + + + + + + diff --git a/OfficeIMO.Excel.Pdf/README.md b/OfficeIMO.Excel.Pdf/README.md new file mode 100644 index 000000000..9c6d99278 --- /dev/null +++ b/OfficeIMO.Excel.Pdf/README.md @@ -0,0 +1,27 @@ +# OfficeIMO.Excel.Pdf + +First-party Excel workbook to PDF export using `OfficeIMO.Pdf`. + +The initial exporter maps everyday worksheet used ranges into reusable PDF tables. It is intentionally thin: workbook reading stays in `OfficeIMO.Excel`, layout and PDF writing stay in `OfficeIMO.Pdf`, and this package only translates worksheet data into PDF document primitives. + +Current scope: + +- All workbook sheets, or a selected sheet list. +- Worksheet used range detection through the existing Excel reader bridge. +- Worksheet print areas when configured. +- Worksheet orientation and margins when configured, with explicit PDF options still available for overrides. +- Hidden workbook worksheets omitted from default all-sheets exports, while explicitly selected hidden sheets can still be exported. +- Hidden worksheet rows and columns omitted by default. +- Repeated print-title rows mapped to PDF table header rows, including repeat-on-page behavior for long tables. +- Manual worksheet row and column page breaks mapped to explicit PDF page breaks between exported table chunks, preserving repeated header/title rows, with `ExcelPdfSaveOptions.UseWorksheetPageBreaks` available to disable that behavior. +- Simple worksheet header/footer text zones, first-page and even-page text variants, and supported header/footer images, including page number, total page count, sheet-name, date, time, workbook file-name, and workbook path tokens. Simple line-level header/footer font family/style, font size, and RGB text color are mapped when the styled text can be represented by one first-party PDF header/footer line style. +- Sheet names as PDF headings. +- Cell display values rendered as PDF table cells, with common number formats, basic cell font emphasis, font color, fill color, two-color conditional color-scale fills, conditional data bars as proportional in-cell PDF table overlays, conditional icon-set indicators as first-party table-cell vector icons, horizontal/vertical alignment, simple cell borders including dashed, dotted, dash-dot, double, and diagonal strokes, external cell hyperlinks, internal workbook links mapped to exact exported-cell PDF named destinations with sheet-level fallback, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, fit-to-width table sizing against effective page margins, and worksheet merged cells mapped through first-party rich table cells, per-cell table fills, per-cell table data bars, per-cell table icons, per-cell table alignment/border/padding overrides, relative table column widths, table max-width caps, row minimum heights, visible-row/column filtering, cell-owned URI and named-destination annotations, and table row/column spans. +- Supported worksheet drawing images anchored into exported PDF table cells when the anchor cell is exported and otherwise emitted as first-party PDF flow images in worksheet anchor order. +- Supported worksheet column, bar, line, area, scatter, radar, pie, and doughnut chart families exported as first-party vector drawing snapshots when the chart data can be read from the workbook. +- `ExcelPdfSaveOptions.Warnings` reports workbook features that are skipped or simplified during export, including mixed or rich per-run header/footer formatting, unsupported header/footer fields, unsupported or unreadable worksheet/header/footer images, unsupported or unreadable chart snapshots, and row truncation when `MaxRowsPerSheet` is used. +- First row styling through the reusable PDF table header model. +- Page size and margin options through first-party `OfficeIMO.Pdf` geometry types. +- Poppler visual baseline coverage for a daily two-sheet workbook with worksheet header/footer text and images, orientation/margins, merged title cells, fills/borders, number formats, explicit row/column sizing, hidden row/column filtering, worksheet images anchored into exported table cells, chart snapshots, and internal/external links. + +Planned scope includes richer worksheet header/footer formatting beyond the current line-level style mapping, richer fit-to-height and automatic multi-page pagination/scaling, richer merged-cell edge cases, richer worksheet image placement fidelity beyond exported table-cell anchors, richer chart fidelity beyond the initial column/bar/line/area/scatter/radar/pie/doughnut snapshots, richer cell style fidelity such as additional conditional formats and locale-specific formats, and broader diagnostics for workbook features that still cannot be mapped faithfully. diff --git a/OfficeIMO.Excel/ExcelCell.cs b/OfficeIMO.Excel/ExcelCell.cs index 2ad2727f1..c29d2f763 100644 --- a/OfficeIMO.Excel/ExcelCell.cs +++ b/OfficeIMO.Excel/ExcelCell.cs @@ -5,7 +5,7 @@ namespace OfficeIMO.Excel { /// /// Lightweight object model wrapper for a single worksheet cell. /// - public sealed class ExcelCell { + public sealed partial class ExcelCell { internal ExcelCell(ExcelSheet sheet, int row, int column) { Sheet = sheet ?? throw new ArgumentNullException(nameof(sheet)); Row = row; @@ -149,6 +149,14 @@ public ExcelCell SetBorder(BorderStyleValues style, string? hexColor = null) { return this; } + /// + /// Applies diagonal borders to the cell. + /// + public ExcelCell SetDiagonalBorder(BorderStyleValues style, string? hexColor = null, bool diagonalUp = true, bool diagonalDown = true) { + Sheet.CellDiagonalBorder(Row, Column, style, hexColor, diagonalUp, diagonalDown); + return this; + } + /// Applies a decimal number format. public ExcelCell Number(int decimals = 2) => SetNumberFormat(ExcelNumberFormats.Get(ExcelNumberPreset.Decimal, decimals)); diff --git a/OfficeIMO.Excel/ExcelChart.cs b/OfficeIMO.Excel/ExcelChart.cs index 5f5dfff14..4368b41ea 100644 --- a/OfficeIMO.Excel/ExcelChart.cs +++ b/OfficeIMO.Excel/ExcelChart.cs @@ -43,6 +43,70 @@ public string Name { /// public ExcelChartDataRange? DataRange => _dataRange; + /// + /// Gets the detected chart type. + /// + public ExcelChartType ChartType { + get { + C.PlotArea? plotArea = GetChart().GetFirstChild(); + return plotArea == null ? ExcelChartType.ColumnClustered : ExcelChartUtils.InferChartType(plotArea); + } + } + + /// + /// Gets the chart title text when present. + /// + public string? Title => GetChartTitleText(GetChart()); + + /// + /// Tries to read the chart data from the chart's source range. + /// + public bool TryGetData(out ExcelChartData data) { + try { + ChartPart chartPart = GetChartPart(); + ExcelChartDataRange? range = _dataRange ?? ExcelChartUtils.TryExtractDataRange(chartPart); + if (range == null) { + data = null!; + return false; + } + + ExcelSheet sheet = _document[range.SheetName]; + ExcelChartData? chartData = ExcelChartUtils.TryReadChartData(sheet, range); + if (chartData == null) { + data = null!; + return false; + } + + _dataRange = range; + data = chartData; + return true; + } catch { + data = null!; + return false; + } + } + + /// + /// Tries to create a dependency-free snapshot for rendering/export consumers. + /// + public bool TryGetSnapshot(out ExcelChartSnapshot snapshot) { + if (!TryGetData(out ExcelChartData data)) { + snapshot = null!; + return false; + } + + snapshot = new ExcelChartSnapshot( + Name, + Title, + ChartType, + data, + GetAnchorRow(), + GetAnchorColumn(), + GetAnchorWidthPixels(), + GetAnchorHeightPixels()); + return true; + } + /// /// Gets whether the chart declares a pivot table source. /// @@ -3609,6 +3673,55 @@ private C.Chart GetChart() { return chart; } + private static string? GetChartTitleText(C.Chart chart) { + C.Title? title = chart.GetFirstChild(); + if (title == null) { + return null; + } + + C.ChartText? chartText = title.GetFirstChild(); + if (chartText == null) { + return null; + } + + string text = string.Concat(chartText.Descendants().Select(item => item.Text)); + return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + } + + private int GetAnchorRow() { + Xdr.FromMarker? marker = _frame.Ancestors().FirstOrDefault()?.FromMarker + ?? _frame.Ancestors().FirstOrDefault()?.FromMarker; + return ParseOneBasedMarker(marker?.RowId?.Text); + } + + private int GetAnchorColumn() { + Xdr.FromMarker? marker = _frame.Ancestors().FirstOrDefault()?.FromMarker + ?? _frame.Ancestors().FirstOrDefault()?.FromMarker; + return ParseOneBasedMarker(marker?.ColumnId?.Text); + } + + private int GetAnchorWidthPixels() { + long? emu = _frame.Ancestors().FirstOrDefault()?.Extent?.Cx?.Value; + return EmuToPixels(emu, 480); + } + + private int GetAnchorHeightPixels() { + long? emu = _frame.Ancestors().FirstOrDefault()?.Extent?.Cy?.Value; + return EmuToPixels(emu, 320); + } + + private static int ParseOneBasedMarker(string? value) { + return int.TryParse(value, out int zeroBased) && zeroBased >= 0 ? zeroBased + 1 : 1; + } + + private static int EmuToPixels(long? emu, int fallback) { + if (!emu.HasValue || emu.Value <= 0) { + return fallback; + } + + return Math.Max(1, (int)Math.Round(emu.Value / 9525D)); + } + private ChartPart GetChartPart() { C.ChartReference? chartReference = _frame.Graphic?.GraphicData?.GetFirstChild(); StringValue? relationshipId = chartReference?.Id; diff --git a/OfficeIMO.Excel/ExcelChartSnapshot.cs b/OfficeIMO.Excel/ExcelChartSnapshot.cs new file mode 100644 index 000000000..759218bc6 --- /dev/null +++ b/OfficeIMO.Excel/ExcelChartSnapshot.cs @@ -0,0 +1,51 @@ +using System; + +namespace OfficeIMO.Excel { + /// + /// Lightweight chart snapshot that consumers can render without depending on Excel or Open XML chart internals. + /// + public sealed class ExcelChartSnapshot { + internal ExcelChartSnapshot( + string name, + string? title, + ExcelChartType chartType, + ExcelChartData data, + int rowIndex, + int columnIndex, + int widthPixels, + int heightPixels) { + Name = name ?? string.Empty; + Title = title; + ChartType = chartType; + Data = data ?? throw new ArgumentNullException(nameof(data)); + RowIndex = rowIndex; + ColumnIndex = columnIndex; + WidthPixels = widthPixels; + HeightPixels = heightPixels; + } + + /// Chart drawing name. + public string Name { get; } + + /// Chart title text when present. + public string? Title { get; } + + /// Detected chart type. + public ExcelChartType ChartType { get; } + + /// Cached or worksheet-backed chart data. + public ExcelChartData Data { get; } + + /// One-based worksheet row where the chart is anchored when known. + public int RowIndex { get; } + + /// One-based worksheet column where the chart is anchored when known. + public int ColumnIndex { get; } + + /// Chart width in pixels when known. + public int WidthPixels { get; } + + /// Chart height in pixels when known. + public int HeightPixels { get; } + } +} diff --git a/OfficeIMO.Excel/ExcelChartUtils.cs b/OfficeIMO.Excel/ExcelChartUtils.cs index 379217115..a6a3de206 100644 --- a/OfficeIMO.Excel/ExcelChartUtils.cs +++ b/OfficeIMO.Excel/ExcelChartUtils.cs @@ -164,7 +164,7 @@ private static List BuildSeriesDescriptors(ExcelChartDataRange return descriptors; } - private static ExcelChartType InferChartType(PlotArea plotArea) { + internal static ExcelChartType InferChartType(PlotArea plotArea) { if (plotArea.GetFirstChild() is BarChart barChart) { BarDirectionValues direction = barChart.GetFirstChild()?.Val ?? BarDirectionValues.Column; BarGroupingValues grouping = barChart.GetFirstChild()?.Val ?? BarGroupingValues.Clustered; diff --git a/OfficeIMO.Excel/ExcelDocument.Inspection.cs b/OfficeIMO.Excel/ExcelDocument.Inspection.cs index 57e8a212a..aeeae8c79 100644 --- a/OfficeIMO.Excel/ExcelDocument.Inspection.cs +++ b/OfficeIMO.Excel/ExcelDocument.Inspection.cs @@ -589,8 +589,11 @@ private static IReadOnlyList BuildDataValidationSna var right = BuildBorderSideSnapshot(border.RightBorder); var top = BuildBorderSideSnapshot(border.TopBorder); var bottom = BuildBorderSideSnapshot(border.BottomBorder); + var diagonal = BuildBorderSideSnapshot(border.DiagonalBorder); + bool diagonalUp = border.DiagonalUp?.Value == true; + bool diagonalDown = border.DiagonalDown?.Value == true; - if (left == null && right == null && top == null && bottom == null) { + if (left == null && right == null && top == null && bottom == null && (!diagonalUp && !diagonalDown || diagonal == null)) { return null; } @@ -599,6 +602,9 @@ private static IReadOnlyList BuildDataValidationSna Right = right, Top = top, Bottom = bottom, + Diagonal = diagonal, + DiagonalUp = diagonalUp, + DiagonalDown = diagonalDown, }; } diff --git a/OfficeIMO.Excel/ExcelImage.cs b/OfficeIMO.Excel/ExcelImage.cs index 4f6fa2445..d98960dc7 100644 --- a/OfficeIMO.Excel/ExcelImage.cs +++ b/OfficeIMO.Excel/ExcelImage.cs @@ -67,6 +67,46 @@ public bool IsAspectRatioLocked { } } + /// + /// Gets the 1-based row index where the image is anchored, when available. + /// + public int RowIndex => GetMarkerRow() + 1; + + /// + /// Gets the 1-based column index where the image is anchored, when available. + /// + public int ColumnIndex => GetMarkerColumn() + 1; + + /// + /// Gets the image width in pixels from the drawing extent. + /// + public int WidthPixels => EmuToPx(GetExtentCx()); + + /// + /// Gets the image height in pixels from the drawing extent. + /// + public int HeightPixels => EmuToPx(GetExtentCy()); + + /// + /// Gets the image content type, such as image/png or image/jpeg. + /// + public string ContentType => ImagePart?.ContentType ?? string.Empty; + + /// + /// Returns a copy of the image bytes from the worksheet drawing relationship. + /// + public byte[] GetBytes() { + ImagePart? imagePart = ImagePart; + if (imagePart == null) { + return Array.Empty(); + } + + using Stream source = imagePart.GetStream(); + using var destination = new MemoryStream(); + source.CopyTo(destination); + return destination.ToArray(); + } + /// /// Sets image title and description metadata. /// @@ -145,10 +185,63 @@ public ExcelImage SetSize(int widthPixels, int heightPixels) { private Xdr.NonVisualDrawingProperties? DrawingProperties => _picture.NonVisualPictureProperties?.NonVisualDrawingProperties; + private ImagePart? ImagePart { + get { + string? relationshipId = _picture.BlipFill?.Blip?.Embed?.Value; + if (string.IsNullOrWhiteSpace(relationshipId)) { + return null; + } + + try { + return _drawingsPart.GetPartById(relationshipId!) as ImagePart; + } catch (ArgumentOutOfRangeException) { + return null; + } + } + } + private void Save() { _drawingsPart.WorksheetDrawing?.Save(); } private static long PxToEmu(int px) => (long)Math.Round(px * 9525.0); + + private static int EmuToPx(long emu) { + if (emu <= 0) { + return 0; + } + + return (int)Math.Max(1, Math.Round(emu / 9525.0)); + } + + private int GetMarkerRow() { + string? row = _anchor.GetFirstChild()?.RowId?.Text; + return int.TryParse(row, out int value) && value >= 0 ? value : 0; + } + + private int GetMarkerColumn() { + string? column = _anchor.GetFirstChild()?.ColumnId?.Text; + return int.TryParse(column, out int value) && value >= 0 ? value : 0; + } + + private long GetExtentCx() { + long? anchorExtent = _anchor.GetFirstChild()?.Cx?.Value; + if (anchorExtent.HasValue && anchorExtent.Value > 0) { + return anchorExtent.Value; + } + + long? shapeExtent = _picture.ShapeProperties?.GetFirstChild()?.GetFirstChild()?.Cx?.Value; + return shapeExtent.GetValueOrDefault(); + } + + private long GetExtentCy() { + long? anchorExtent = _anchor.GetFirstChild()?.Cy?.Value; + if (anchorExtent.HasValue && anchorExtent.Value > 0) { + return anchorExtent.Value; + } + + long? shapeExtent = _picture.ShapeProperties?.GetFirstChild()?.GetFirstChild()?.Cy?.Value; + return shapeExtent.GetValueOrDefault(); + } } } diff --git a/OfficeIMO.Excel/ExcelInspectionSnapshot.cs b/OfficeIMO.Excel/ExcelInspectionSnapshot.cs index e69134e70..3ebf5b33c 100644 --- a/OfficeIMO.Excel/ExcelInspectionSnapshot.cs +++ b/OfficeIMO.Excel/ExcelInspectionSnapshot.cs @@ -746,6 +746,31 @@ public sealed class ExcelCellStyleSnapshot { /// public string? FillColorArgb { get; internal set; } + /// + /// Font color in RRGGBB hexadecimal form, when directly resolvable. + /// + public string? FontColorHex => ToRgbHex(FontColorArgb); + + /// + /// Fill color in RRGGBB hexadecimal form, when directly resolvable. + /// + public string? FillColorHex => ToRgbHex(FillColorArgb); + + /// + /// Whether the snapshot carries simple visual styling that can be mapped into PDF table output. + /// + public bool HasPdfVisualStyle => + Bold || + Italic || + Underline || + FontColorArgb != null || + FillColorArgb != null || + NumberFormatId != 0U || + NumberFormatCode != null || + Border != null || + HorizontalAlignment != null || + VerticalAlignment != null; + /// /// Border metadata resolved for the cell style, when available. /// @@ -765,6 +790,15 @@ public sealed class ExcelCellStyleSnapshot { /// Whether wrap text is enabled. /// public bool WrapText { get; internal set; } + + private static string? ToRgbHex(string? argb) { + if (string.IsNullOrWhiteSpace(argb)) { + return null; + } + + string value = argb!.Trim(); + return value.Length == 8 ? value.Substring(2) : value.Length == 6 ? value : null; + } } /// @@ -790,6 +824,21 @@ public sealed class ExcelCellBorderSnapshot { /// Bottom border side. /// public ExcelBorderSideSnapshot? Bottom { get; internal set; } + + /// + /// Diagonal border side. + /// + public ExcelBorderSideSnapshot? Diagonal { get; internal set; } + + /// + /// Whether the diagonal border runs from bottom-left to top-right. + /// + public bool DiagonalUp { get; internal set; } + + /// + /// Whether the diagonal border runs from top-left to bottom-right. + /// + public bool DiagonalDown { get; internal set; } } /// diff --git a/OfficeIMO.Excel/ExcelRuleInfo.cs b/OfficeIMO.Excel/ExcelRuleInfo.cs index 796f31221..e696dbc54 100644 --- a/OfficeIMO.Excel/ExcelRuleInfo.cs +++ b/OfficeIMO.Excel/ExcelRuleInfo.cs @@ -15,6 +15,16 @@ public sealed class ExcelConditionalFormattingInfo { public bool StopIfTrue { get; set; } /// Gets or sets formulas attached to the rule. public IReadOnlyList Formulas { get; set; } = Array.Empty(); + /// Gets or sets ARGB colors attached to a color-scale rule, in rule order. + public IReadOnlyList ColorScaleColors { get; set; } = Array.Empty(); + /// Gets or sets the ARGB color attached to a data-bar rule. + public string? DataBarColor { get; set; } + /// Gets or sets the icon-set name attached to an icon-set rule. + public string? IconSet { get; set; } + /// Gets or sets whether the icon-set rule displays the underlying cell value. + public bool IconSetShowValue { get; set; } = true; + /// Gets or sets whether the icon-set rule reverses icon order. + public bool IconSetReverse { get; set; } } /// diff --git a/OfficeIMO.Excel/ExcelSheet.CellStyle.cs b/OfficeIMO.Excel/ExcelSheet.CellStyle.cs new file mode 100644 index 000000000..712340110 --- /dev/null +++ b/OfficeIMO.Excel/ExcelSheet.CellStyle.cs @@ -0,0 +1,175 @@ +using DocumentFormat.OpenXml.Spreadsheet; +using DocumentFormat.OpenXml.Packaging; + +namespace OfficeIMO.Excel { + public partial class ExcelCell { + /// + /// Gets a read-only snapshot of the cell's visual style. + /// + public ExcelCellStyleSnapshot GetStyle() => Sheet.GetCellStyle(Row, Column); + } + + public partial class ExcelSheet { + /// + /// Gets a read-only snapshot of the visual style assigned to a worksheet cell. + /// + public ExcelCellStyleSnapshot GetCellStyle(int row, int column) { + Cell? cell = TryGetExistingCell(row, column); + if (cell == null) { + return new ExcelCellStyleSnapshot(); + } + + WorkbookPart? workbookPart = _excelDocument.WorkbookPartRoot; + Stylesheet? stylesheet = workbookPart?.WorkbookStylesPart?.Stylesheet; + if (stylesheet == null) { + return new ExcelCellStyleSnapshot { + StyleIndex = cell.StyleIndex?.Value ?? 0U + }; + } + + CellFormat format = GetBaseCellFormat(stylesheet, cell.StyleIndex?.Value ?? 0U); + Font? font = stylesheet.Fonts?.Elements().ElementAtOrDefault((int)(format.FontId?.Value ?? 0U)); + Fill? fill = stylesheet.Fills?.Elements().ElementAtOrDefault((int)(format.FillId?.Value ?? 0U)); + Border? border = stylesheet.Borders?.Elements().ElementAtOrDefault((int)(format.BorderId?.Value ?? 0U)); + uint numberFormatId = format.NumberFormatId?.Value ?? 0U; + string? numberFormatCode = GetNumberFormatCode(stylesheet, numberFormatId); + + return new ExcelCellStyleSnapshot { + StyleIndex = cell.StyleIndex?.Value ?? 0U, + NumberFormatId = numberFormatId, + NumberFormatCode = numberFormatCode, + IsDateLike = IsBuiltInDate(numberFormatId) || ExcelNumberFormatClassifier.LooksLikeDateFormat(numberFormatCode), + Bold = font?.Bold != null, + Italic = font?.Italic != null, + Underline = font?.Underline != null, + FontColorArgb = NormalizeReadArgb(font?.Color?.Rgb?.Value), + FillColorArgb = GetFillArgb(fill), + Border = BuildBorderSnapshot(border), + HorizontalAlignment = format.Alignment?.Horizontal?.InnerText, + VerticalAlignment = format.Alignment?.Vertical?.InnerText, + WrapText = format.Alignment?.WrapText?.Value == true + }; + } + + private static string? GetFillArgb(Fill? fill) { + PatternFill? pattern = fill?.PatternFill; + if (pattern == null || pattern.PatternType?.Value != PatternValues.Solid) { + return null; + } + + return NormalizeReadArgb(pattern.ForegroundColor?.Rgb?.Value) + ?? NormalizeReadArgb(pattern.BackgroundColor?.Rgb?.Value); + } + + private static ExcelCellBorderSnapshot? BuildBorderSnapshot(Border? border) { + if (border == null) { + return null; + } + + ExcelBorderSideSnapshot? left = BuildBorderSideSnapshot(border.LeftBorder); + ExcelBorderSideSnapshot? right = BuildBorderSideSnapshot(border.RightBorder); + ExcelBorderSideSnapshot? top = BuildBorderSideSnapshot(border.TopBorder); + ExcelBorderSideSnapshot? bottom = BuildBorderSideSnapshot(border.BottomBorder); + ExcelBorderSideSnapshot? diagonal = BuildBorderSideSnapshot(border.DiagonalBorder); + bool diagonalUp = border.DiagonalUp?.Value == true; + bool diagonalDown = border.DiagonalDown?.Value == true; + if (left == null && right == null && top == null && bottom == null && (!diagonalUp && !diagonalDown || diagonal == null)) { + return null; + } + + return new ExcelCellBorderSnapshot { + Left = left, + Right = right, + Top = top, + Bottom = bottom, + Diagonal = diagonal, + DiagonalUp = diagonalUp, + DiagonalDown = diagonalDown + }; + } + + private static ExcelBorderSideSnapshot? BuildBorderSideSnapshot(BorderPropertiesType? borderSide) { + if (borderSide == null) { + return null; + } + + string? style = ExtractBorderStyle(borderSide); + string? colorArgb = NormalizeReadArgb(borderSide.GetFirstChild()?.Rgb?.Value); + if (string.IsNullOrWhiteSpace(style) && string.IsNullOrWhiteSpace(colorArgb)) { + return null; + } + + return new ExcelBorderSideSnapshot { + Style = style, + ColorArgb = colorArgb + }; + } + + private static string? ExtractBorderStyle(BorderPropertiesType borderSide) { + string xml = borderSide.OuterXml; + if (string.IsNullOrWhiteSpace(xml)) { + return null; + } + + const string marker = "style=\""; + int index = xml.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (index < 0) { + return null; + } + + index += marker.Length; + int endIndex = xml.IndexOf('"', index); + if (endIndex <= index) { + return null; + } + + string value = xml.Substring(index, endIndex - index); + return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + } + + private static string? GetNumberFormatCode(Stylesheet stylesheet, uint numberFormatId) { + if (stylesheet.NumberingFormats != null) { + foreach (NumberingFormat numberingFormat in stylesheet.NumberingFormats.Elements()) { + if (numberingFormat.NumberFormatId?.Value == numberFormatId) { + return numberingFormat.FormatCode?.Value; + } + } + } + + return GetBuiltInNumberFormatCode(numberFormatId); + } + + private static bool IsBuiltInDate(uint numberFormatId) => + numberFormatId is 14 or 15 or 16 or 17 or 18 or 19 or 20 or 21 or 22 + or 27 or 30 or 36 or 45 or 46 or 47; + + private static string? NormalizeReadArgb(string? value) { + if (string.IsNullOrWhiteSpace(value)) { + return null; + } + + string hex = value!.Trim(); + if (hex.StartsWith("#", StringComparison.Ordinal)) { + hex = hex.Substring(1); + } + + if (hex.Length == 6) { + hex = "FF" + hex; + } else if (hex.Length != 8) { + return null; + } + + for (int i = 0; i < hex.Length; i++) { + char ch = hex[i]; + bool isHex = (ch >= '0' && ch <= '9') || + (ch >= 'a' && ch <= 'f') || + (ch >= 'A' && ch <= 'F'); + if (!isHex) { + return null; + } + } + + return hex.ToUpperInvariant(); + } + } +} diff --git a/OfficeIMO.Excel/ExcelSheet.CellValue.cs b/OfficeIMO.Excel/ExcelSheet.CellValue.cs index ebb84872c..7fc7c0262 100644 --- a/OfficeIMO.Excel/ExcelSheet.CellValue.cs +++ b/OfficeIMO.Excel/ExcelSheet.CellValue.cs @@ -1590,6 +1590,34 @@ public void CellBorder(int row, int column, BorderStyleValues style, string? hex }); } + /// + /// Applies diagonal border lines to a single cell. + /// + /// The 1-based row index of the cell to style. + /// The 1-based column index of the cell to style. + /// The diagonal border style. + /// Optional border color expressed as ARGB or RGB hex. + /// Whether to draw the bottom-left to top-right diagonal. + /// Whether to draw the top-left to bottom-right diagonal. + public void CellDiagonalBorder(int row, int column, BorderStyleValues style, string? hexColor = null, bool diagonalUp = true, bool diagonalDown = true) { + WriteLockConditional(() => { + var cell = GetCell(row, column); + var workbookPart = _excelDocument.WorkbookPartRoot ?? throw new InvalidOperationException("WorkbookPart is null"); + var stylesPart = workbookPart.WorkbookStylesPart ?? workbookPart.AddNewPart(); + var stylesheet = stylesPart.Stylesheet ??= new Stylesheet(); + EnsureDefaultStylePrimitives(stylesheet); + + var baseFormat = GetBaseCellFormat(stylesheet, cell.StyleIndex?.Value ?? 0U); + var borderId = GetOrCreateBorderVariant(stylesheet, GetOptionalValue(baseFormat.BorderId), border => SetDiagonalBorder(border, style, hexColor, diagonalUp, diagonalDown)); + ApplyCellFormatOverride(stylesheet, cell, format => { + format.BorderId = borderId; + format.ApplyBorder = true; + }); + + stylesPart.Stylesheet.Save(); + }); + } + /// /// Applies a font color (ARGB hex or #RRGGBB) to a single cell. /// @@ -1969,6 +1997,13 @@ private static void SetUniformBorder(Border border, BorderStyleValues style, str border.BottomBorder = CreateBorderSide(style, argb); } + private static void SetDiagonalBorder(Border border, BorderStyleValues style, string? hexColor, bool diagonalUp, bool diagonalDown) { + var argb = string.IsNullOrWhiteSpace(hexColor) ? null : NormalizeHexColor(hexColor!); + border.DiagonalBorder = CreateBorderSide(style, argb); + border.DiagonalUp = diagonalUp; + border.DiagonalDown = diagonalDown; + } + private static T CreateBorderSide(BorderStyleValues style, string? argb) where T : BorderPropertiesType, new() { var side = new T { Style = style diff --git a/OfficeIMO.Excel/ExcelSheet.ColumnDefinitions.cs b/OfficeIMO.Excel/ExcelSheet.ColumnDefinitions.cs new file mode 100644 index 000000000..5d41f5060 --- /dev/null +++ b/OfficeIMO.Excel/ExcelSheet.ColumnDefinitions.cs @@ -0,0 +1,34 @@ +using DocumentFormat.OpenXml.Spreadsheet; + +namespace OfficeIMO.Excel { + public partial class ExcelSheet { + /// + /// Gets explicit worksheet column definitions such as custom widths and hidden ranges. + /// + public IReadOnlyList GetColumnDefinitions() { + Columns? columns = WorksheetRoot.GetFirstChild(); + if (columns == null) { + return Array.Empty(); + } + + var result = new List(); + foreach (Column column in columns.Elements()) { + int start = checked((int)(column.Min?.Value ?? 0U)); + int end = checked((int)(column.Max?.Value ?? 0U)); + if (start <= 0 || end <= 0 || end < start) { + continue; + } + + result.Add(new ExcelColumnSnapshot { + StartIndex = start, + EndIndex = end, + Width = column.Width?.Value, + Hidden = column.Hidden?.Value == true, + CustomWidth = column.CustomWidth?.Value == true + }); + } + + return result.AsReadOnly(); + } + } +} diff --git a/OfficeIMO.Excel/ExcelSheet.ColumnsRows.cs b/OfficeIMO.Excel/ExcelSheet.ColumnsRows.cs index 85eb97253..79d29806f 100644 --- a/OfficeIMO.Excel/ExcelSheet.ColumnsRows.cs +++ b/OfficeIMO.Excel/ExcelSheet.ColumnsRows.cs @@ -1064,17 +1064,31 @@ private double GetColumnWidthUnits(int columnIndex) { return 8.43; // Excel's default width for Calibri 11 } - private void SetRowHeightCore(int rowIndex, double height) { + private static double NormalizeRowHeight(double height) { + if (double.IsNaN(height) || double.IsInfinity(height)) { + return 0; + } + + if (height <= 0) { + return 0; + } + + return Math.Min(height, 409D); + } + + private void SetRowHeightCore(int rowIndex, double height, bool normalizeForExcelVisibleHeight = false) { var worksheet = WorksheetRoot; SheetData? sheetData = worksheet.GetFirstChild(); if (sheetData == null) return; Row? row = sheetData.Elements().FirstOrDefault(r => r.RowIndex != null && r.RowIndex.Value == (uint)rowIndex); if (row == null) return; + height = NormalizeRowHeight(height); if (height > 0) { - // Excel normalizes OfficeIMO-authored row heights down on open/save; serialize a - // pixel-equivalent height so the visible Excel row height matches the measured value. - row.Height = Math.Round(height * 1.5, 2); + double storedHeight = normalizeForExcelVisibleHeight + ? height * 1.5 + : height; + row.Height = Math.Round(storedHeight, 2); row.CustomHeight = true; } else { row.Height = null; @@ -1131,7 +1145,9 @@ public void AutoFitRows(ExecutionMode? mode = null, CancellationToken ct = defau } for (int i = 0; i < rowIndexes.Count; i++) { - SetRowHeightCore(rowIndexes[i], computed[i]); + // Excel normalizes OfficeIMO-authored auto-fit row heights down on open/save; serialize a + // pixel-equivalent height so the visible Excel row height matches the measured value. + SetRowHeightCore(rowIndexes[i], computed[i], normalizeForExcelVisibleHeight: true); } UpdateSheetFormat(); @@ -1161,7 +1177,9 @@ public void AutoFitRows(ExecutionMode? mode = null, CancellationToken ct = defau applySequential: () => { // Apply phase - write all row heights to DOM for (int i = 0; i < rowIndexes.Count; i++) { - SetRowHeightCore(rowIndexes[i], computed[i]); + // Excel normalizes OfficeIMO-authored auto-fit row heights down on open/save; serialize a + // pixel-equivalent height so the visible Excel row height matches the measured value. + SetRowHeightCore(rowIndexes[i], computed[i], normalizeForExcelVisibleHeight: true); } UpdateSheetFormat(); if (EffectiveExecution.SaveWorksheetAfterAutoFit) { @@ -1439,6 +1457,25 @@ public void SetColumnHidden(int columnIndex, bool hidden) { }); } + /// + /// Sets whether the specified row is hidden. + /// + /// 1-based row index. + /// True to hide the row; false to show it. + public void SetRowHidden(int rowIndex, bool hidden) { + if (rowIndex <= 0) { + return; + } + + _excelDocument.MaterializeDeferredDataSetImport(); + WriteLock(() => { + SheetData sheetData = GetOrCreateSheetData(); + Row row = GetOrCreateRowElement(sheetData, rowIndex); + row.Hidden = hidden ? true : (bool?)null; + WorksheetRoot.Save(); + }); + } + /// /// Auto-fits the height of the specified row based on its contents. /// @@ -1446,7 +1483,9 @@ public void SetColumnHidden(int columnIndex, bool hidden) { public void AutoFitRow(int rowIndex) { WriteLockConditional(() => { var height = CalculateRowHeight(rowIndex); - SetRowHeightCore(rowIndex, height); + // Excel normalizes OfficeIMO-authored auto-fit row heights down on open/save; serialize a + // pixel-equivalent height so the visible Excel row height matches the measured value. + SetRowHeightCore(rowIndex, height, normalizeForExcelVisibleHeight: true); UpdateSheetFormat(); if (EffectiveExecution.SaveWorksheetAfterAutoFit) { WorksheetRoot.Save(); @@ -1454,6 +1493,27 @@ public void AutoFitRow(int rowIndex) { }); } + /// + /// Sets the explicit height of the specified row in points. Use a non-positive height to clear the custom row height. + /// + /// 1-based row index. + /// Row height in points. + public void SetRowHeight(int rowIndex, double height) { + if (rowIndex <= 0) { + return; + } + + height = NormalizeRowHeight(height); + _excelDocument.MaterializeDeferredDataSetImport(); + WriteLock(() => { + SheetData sheetData = GetOrCreateSheetData(); + GetOrCreateRowElement(sheetData, rowIndex); + SetRowHeightCore(rowIndex, height); + UpdateSheetFormat(); + WorksheetRoot.Save(); + }); + } + /// /// Freezes panes on the worksheet. /// diff --git a/OfficeIMO.Excel/ExcelSheet.HeadersFooters.cs b/OfficeIMO.Excel/ExcelSheet.HeadersFooters.cs index c7556242e..025fbcbc5 100644 --- a/OfficeIMO.Excel/ExcelSheet.HeadersFooters.cs +++ b/OfficeIMO.Excel/ExcelSheet.HeadersFooters.cs @@ -3,6 +3,7 @@ using DocumentFormat.OpenXml.Spreadsheet; using OfficeIMO.Drawing; using System.Globalization; +using System.Xml.Linq; namespace OfficeIMO.Excel { public partial class ExcelSheet { @@ -22,6 +23,30 @@ public sealed class HeaderFooterSnapshot { public string FooterCenter { get; set; } = string.Empty; /// Right section text of the footer (odd pages). public string FooterRight { get; set; } = string.Empty; + /// Left section text of the header (first page). + public string FirstHeaderLeft { get; set; } = string.Empty; + /// Center section text of the header (first page). + public string FirstHeaderCenter { get; set; } = string.Empty; + /// Right section text of the header (first page). + public string FirstHeaderRight { get; set; } = string.Empty; + /// Left section text of the footer (first page). + public string FirstFooterLeft { get; set; } = string.Empty; + /// Center section text of the footer (first page). + public string FirstFooterCenter { get; set; } = string.Empty; + /// Right section text of the footer (first page). + public string FirstFooterRight { get; set; } = string.Empty; + /// Left section text of the header (even pages). + public string EvenHeaderLeft { get; set; } = string.Empty; + /// Center section text of the header (even pages). + public string EvenHeaderCenter { get; set; } = string.Empty; + /// Right section text of the header (even pages). + public string EvenHeaderRight { get; set; } = string.Empty; + /// Left section text of the footer (even pages). + public string EvenFooterLeft { get; set; } = string.Empty; + /// Center section text of the footer (even pages). + public string EvenFooterCenter { get; set; } = string.Empty; + /// Right section text of the footer (even pages). + public string EvenFooterRight { get; set; } = string.Empty; /// First page has different header/footer. public bool DifferentFirstPage { get; set; } /// Odd and even pages have different headers/footers. @@ -30,6 +55,42 @@ public sealed class HeaderFooterSnapshot { public bool HeaderHasPicturePlaceholder { get; set; } /// True if any footer section contains the picture placeholder (&G). public bool FooterHasPicturePlaceholder { get; set; } + /// Left section image of the header (odd pages), when available. + public HeaderFooterImageSnapshot? HeaderLeftImage { get; set; } + /// Center section image of the header (odd pages), when available. + public HeaderFooterImageSnapshot? HeaderCenterImage { get; set; } + /// Right section image of the header (odd pages), when available. + public HeaderFooterImageSnapshot? HeaderRightImage { get; set; } + /// Left section image of the footer (odd pages), when available. + public HeaderFooterImageSnapshot? FooterLeftImage { get; set; } + /// Center section image of the footer (odd pages), when available. + public HeaderFooterImageSnapshot? FooterCenterImage { get; set; } + /// Right section image of the footer (odd pages), when available. + public HeaderFooterImageSnapshot? FooterRightImage { get; set; } + } + + /// + /// Snapshot of an Excel header/footer image. + /// + public sealed class HeaderFooterImageSnapshot { + internal HeaderFooterImageSnapshot(HeaderFooterPosition position, byte[] bytes, string contentType, double widthPoints, double heightPoints) { + Position = position; + Bytes = bytes; + ContentType = contentType; + WidthPoints = widthPoints; + HeightPoints = heightPoints; + } + + /// Header/footer section position. + public HeaderFooterPosition Position { get; } + /// Image bytes. + public byte[] Bytes { get; } + /// Image content type, such as image/png or image/jpeg. + public string ContentType { get; } + /// Image width in points. + public double WidthPoints { get; } + /// Image height in points. + public double HeightPoints { get; } } internal static string NormalizeImageContentType(string? contentType, string parameterName) { @@ -52,44 +113,29 @@ public HeaderFooterSnapshot GetHeaderFooter() { var hf = ws.GetFirstChild(); string oddHeader = hf?.OddHeader?.Text ?? string.Empty; string oddFooter = hf?.OddFooter?.Text ?? string.Empty; - - (string L, string C, string R) Parse(string text) { - string l = string.Empty, c = string.Empty, r = string.Empty; - if (string.IsNullOrEmpty(text)) return (l, c, r); - int i = 0; - while (i < text.Length) { - char ch = text[i++]; - if (ch == '&' && i < text.Length) { - char sec = text[i++]; - if (sec == 'L' || sec == 'C' || sec == 'R') { - var sb = new StringBuilder(); - while (i < text.Length) { - if (text[i] == '&' && i + 1 < text.Length) { - char nxt = text[i + 1]; - if (nxt == 'L' || nxt == 'C' || nxt == 'R') break; - } - sb.Append(text[i++]); - } - string val = sb.ToString(); - if (sec == 'L') l = val; else if (sec == 'C') c = val; else r = val; - } - } - } - return (l ?? string.Empty, c ?? string.Empty, r ?? string.Empty); - } - - var (hl, hc, hr) = Parse(oddHeader); - var (fl, fc, fr) = Parse(oddFooter); - - // If &G is missing from the text, but a LegacyDrawingHeaderFooter part exists, - // treat it as picture-present (defensive for files where tokens were stripped). - bool hasHeaderImageRel = false, hasFooterImageRel = false; + string firstHeader = hf?.FirstHeader?.Text ?? string.Empty; + string firstFooter = hf?.FirstFooter?.Text ?? string.Empty; + string evenHeader = hf?.EvenHeader?.Text ?? string.Empty; + string evenFooter = hf?.EvenFooter?.Text ?? string.Empty; + + var (hl, hc, hr) = ParseHeaderFooterSections(oddHeader); + var (fl, fc, fr) = ParseHeaderFooterSections(oddFooter); + var (fhl, fhc, fhr) = ParseHeaderFooterSections(firstHeader); + var (ffl, ffc, ffr) = ParseHeaderFooterSections(firstFooter); + var (ehl, ehc, ehr) = ParseHeaderFooterSections(evenHeader); + var (efl, efc, efr) = ParseHeaderFooterSections(evenFooter); + + Dictionary imagesByShapeId = ReadHeaderFooterImages(); + bool hasHeaderImageRel = imagesByShapeId.ContainsKey("LH") || imagesByShapeId.ContainsKey("CH") || imagesByShapeId.ContainsKey("RH"); + bool hasFooterImageRel = imagesByShapeId.ContainsKey("LF") || imagesByShapeId.ContainsKey("CF") || imagesByShapeId.ContainsKey("RF"); try { var legacy = WorksheetRoot.GetFirstChild(); if (legacy?.Id?.Value is string relId && !string.IsNullOrEmpty(relId)) { var part = _worksheetPart.GetPartById(relId); - hasHeaderImageRel = part is VmlDrawingPart; // both header/footer share the same VML part - hasFooterImageRel = hasHeaderImageRel; + if (part is VmlDrawingPart && imagesByShapeId.Count == 0) { + hasHeaderImageRel = true; // defensive for files where VML exists but cannot be parsed. + hasFooterImageRel = true; + } } } catch { /* ignore */ } @@ -100,12 +146,182 @@ public HeaderFooterSnapshot GetHeaderFooter() { FooterLeft = fl, FooterCenter = fc, FooterRight = fr, + FirstHeaderLeft = fhl, + FirstHeaderCenter = fhc, + FirstHeaderRight = fhr, + FirstFooterLeft = ffl, + FirstFooterCenter = ffc, + FirstFooterRight = ffr, + EvenHeaderLeft = ehl, + EvenHeaderCenter = ehc, + EvenHeaderRight = ehr, + EvenFooterLeft = efl, + EvenFooterCenter = efc, + EvenFooterRight = efr, DifferentFirstPage = hf?.DifferentFirst?.Value ?? false, DifferentOddEven = hf?.DifferentOddEven?.Value ?? false, HeaderHasPicturePlaceholder = (hl.IndexOf("&G", StringComparison.Ordinal) >= 0) || (hc.IndexOf("&G", StringComparison.Ordinal) >= 0) || (hr.IndexOf("&G", StringComparison.Ordinal) >= 0) || hasHeaderImageRel, - FooterHasPicturePlaceholder = (fl.IndexOf("&G", StringComparison.Ordinal) >= 0) || (fc.IndexOf("&G", StringComparison.Ordinal) >= 0) || (fr.IndexOf("&G", StringComparison.Ordinal) >= 0) || hasFooterImageRel + FooterHasPicturePlaceholder = (fl.IndexOf("&G", StringComparison.Ordinal) >= 0) || (fc.IndexOf("&G", StringComparison.Ordinal) >= 0) || (fr.IndexOf("&G", StringComparison.Ordinal) >= 0) || hasFooterImageRel, + HeaderLeftImage = imagesByShapeId.TryGetValue("LH", out var headerLeftImage) ? headerLeftImage : null, + HeaderCenterImage = imagesByShapeId.TryGetValue("CH", out var headerCenterImage) ? headerCenterImage : null, + HeaderRightImage = imagesByShapeId.TryGetValue("RH", out var headerRightImage) ? headerRightImage : null, + FooterLeftImage = imagesByShapeId.TryGetValue("LF", out var footerLeftImage) ? footerLeftImage : null, + FooterCenterImage = imagesByShapeId.TryGetValue("CF", out var footerCenterImage) ? footerCenterImage : null, + FooterRightImage = imagesByShapeId.TryGetValue("RF", out var footerRightImage) ? footerRightImage : null }; } + + private Dictionary ReadHeaderFooterImages() { + var images = new Dictionary(StringComparer.OrdinalIgnoreCase); + VmlDrawingPart? vmlPart = null; + try { + var legacy = WorksheetRoot.GetFirstChild(); + if (legacy?.Id?.Value is string relId && !string.IsNullOrWhiteSpace(relId)) { + vmlPart = _worksheetPart.GetPartById(relId) as VmlDrawingPart; + } + } catch { + return images; + } + + if (vmlPart == null) { + return images; + } + + XDocument vmlDocument; + try { + using Stream stream = vmlPart.GetStream(FileMode.Open, FileAccess.Read); + vmlDocument = XDocument.Load(stream); + } catch { + return images; + } + + foreach (XElement shape in vmlDocument.Descendants().Where(element => string.Equals(element.Name.LocalName, "shape", StringComparison.OrdinalIgnoreCase))) { + string? shapeId = shape.Attribute("id")?.Value; + if (string.IsNullOrWhiteSpace(shapeId) || !TryGetHeaderFooterPosition(shapeId!, out bool isHeader, out HeaderFooterPosition position)) { + continue; + } + + XElement? imageData = shape.Descendants().FirstOrDefault(element => string.Equals(element.Name.LocalName, "imagedata", StringComparison.OrdinalIgnoreCase)); + if (imageData == null) { + continue; + } + + string? relationshipId = imageData.Attributes().FirstOrDefault(attribute => + string.Equals(attribute.Name.LocalName, "id", StringComparison.OrdinalIgnoreCase) || + string.Equals(attribute.Name.LocalName, "relid", StringComparison.OrdinalIgnoreCase))?.Value; + if (string.IsNullOrWhiteSpace(relationshipId)) { + continue; + } + + if (TryReadHeaderFooterImage(vmlPart, relationshipId!, shape.Attribute("style")?.Value, position, out HeaderFooterImageSnapshot? image)) { + images[shapeId!] = image!; + } + } + + return images; + } + + private static bool TryReadHeaderFooterImage(VmlDrawingPart vmlPart, string relationshipId, string? style, HeaderFooterPosition position, out HeaderFooterImageSnapshot? image) { + image = null; + ImagePart imagePart; + try { + if (vmlPart.GetPartById(relationshipId) is not ImagePart part) { + return false; + } + + imagePart = part; + } catch { + return false; + } + + byte[] bytes; + using (Stream source = imagePart.GetStream(FileMode.Open, FileAccess.Read)) + using (var destination = new MemoryStream()) { + source.CopyTo(destination); + bytes = destination.ToArray(); + } + + if (bytes.Length == 0) { + return false; + } + + double widthPoints = TryReadStylePoints(style, "width") ?? 0D; + double heightPoints = TryReadStylePoints(style, "height") ?? 0D; + if (widthPoints <= 0D || heightPoints <= 0D) { + try { + var info = OfficeImageReader.Identify(bytes); + if (widthPoints <= 0D) { + widthPoints = info.Width * 72D / info.DpiX; + } + + if (heightPoints <= 0D) { + heightPoints = info.Height * 72D / info.DpiY; + } + } catch { + widthPoints = widthPoints <= 0D ? 144D : widthPoints; + heightPoints = heightPoints <= 0D ? 48D : heightPoints; + } + } + + image = new HeaderFooterImageSnapshot(position, bytes, imagePart.ContentType, widthPoints, heightPoints); + return true; + } + + private static double? TryReadStylePoints(string? style, string propertyName) { + if (string.IsNullOrWhiteSpace(style)) { + return null; + } + + string prefix = propertyName + ":"; + foreach (string segment in style!.Split(';')) { + string trimmed = segment.Trim(); + if (!trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { + continue; + } + + string value = trimmed.Substring(prefix.Length).Trim(); + if (value.EndsWith("pt", StringComparison.OrdinalIgnoreCase)) { + value = value.Substring(0, value.Length - 2).Trim(); + } + + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double points) && points > 0D) { + return points; + } + } + + return null; + } + + private static bool TryGetHeaderFooterPosition(string shapeId, out bool isHeader, out HeaderFooterPosition position) { + isHeader = false; + position = HeaderFooterPosition.Left; + switch (shapeId.ToUpperInvariant()) { + case "LH": + isHeader = true; + position = HeaderFooterPosition.Left; + return true; + case "CH": + isHeader = true; + position = HeaderFooterPosition.Center; + return true; + case "RH": + isHeader = true; + position = HeaderFooterPosition.Right; + return true; + case "LF": + position = HeaderFooterPosition.Left; + return true; + case "CF": + position = HeaderFooterPosition.Center; + return true; + case "RF": + position = HeaderFooterPosition.Right; + return true; + default: + return false; + } + } + /// /// Sets the header and/or footer text for this worksheet. /// @@ -149,16 +365,8 @@ public void SetHeaderFooter( hf.AlignWithMargins = alignWithMargins ? true : (bool?)null; hf.ScaleWithDoc = scaleWithDoc ? true : (bool?)null; - string? Build(string? left, string? center, string? right) { - var sb = new StringBuilder(); - if (!string.IsNullOrEmpty(left)) sb.Append("&L").Append(EscapeHeaderFooter(left)); - if (!string.IsNullOrEmpty(center)) sb.Append("&C").Append(EscapeHeaderFooter(center)); - if (!string.IsNullOrEmpty(right)) sb.Append("&R").Append(EscapeHeaderFooter(right)); - return sb.Length == 0 ? null : sb.ToString(); - } - - var oddHeader = Build(headerLeft, headerCenter, headerRight); - var oddFooter = Build(footerLeft, footerCenter, footerRight); + var oddHeader = BuildHeaderFooterSections(headerLeft, headerCenter, headerRight); + var oddFooter = BuildHeaderFooterSections(footerLeft, footerCenter, footerRight); if (oddHeader != null) hf.OddHeader = new OddHeader(oddHeader); else hf.OddHeader = null; if (oddFooter != null) hf.OddFooter = new OddFooter(oddFooter); else hf.OddFooter = null; @@ -183,6 +391,122 @@ public void SetHeaderFooter( }); } + /// + /// Sets a first-page header and/or footer variant for this worksheet. + /// + public void SetFirstPageHeaderFooter( + string? headerLeft = null, + string? headerCenter = null, + string? headerRight = null, + string? footerLeft = null, + string? footerCenter = null, + string? footerRight = null, + bool enabled = true) { + WriteLock(() => { + HeaderFooter hf = EnsureHeaderFooter(); + hf.DifferentFirst = enabled ? true : (bool?)null; + hf.FirstHeader = enabled ? BuildHeaderFooterSections(headerLeft, headerCenter, headerRight) is string header ? new FirstHeader(header) : null : null; + hf.FirstFooter = enabled ? BuildHeaderFooterSections(footerLeft, footerCenter, footerRight) is string footer ? new FirstFooter(footer) : null : null; + CleanupHeaderFooterPictureArtifacts(); + WorksheetRoot.Save(); + }); + } + + /// + /// Sets an even-page header and/or footer variant for this worksheet. + /// + public void SetEvenPageHeaderFooter( + string? headerLeft = null, + string? headerCenter = null, + string? headerRight = null, + string? footerLeft = null, + string? footerCenter = null, + string? footerRight = null, + bool enabled = true) { + WriteLock(() => { + HeaderFooter hf = EnsureHeaderFooter(); + hf.DifferentOddEven = enabled ? true : (bool?)null; + hf.EvenHeader = enabled ? BuildHeaderFooterSections(headerLeft, headerCenter, headerRight) is string header ? new EvenHeader(header) : null : null; + hf.EvenFooter = enabled ? BuildHeaderFooterSections(footerLeft, footerCenter, footerRight) is string footer ? new EvenFooter(footer) : null : null; + CleanupHeaderFooterPictureArtifacts(); + WorksheetRoot.Save(); + }); + } + + private HeaderFooter EnsureHeaderFooter() { + var ws = WorksheetRoot; + var hf = ws.GetFirstChild(); + if (hf != null) { + return hf; + } + + hf = new HeaderFooter(); + var drawing = ws.GetFirstChild(); + if (drawing != null) { + ws.InsertBefore(hf, drawing); + } else { + var after = ws.GetFirstChild(); + if (after != null) { + ws.InsertAfter(hf, after); + } else { + ws.Append(hf); + } + } + + return hf; + } + + private static (string L, string C, string R) ParseHeaderFooterSections(string text) { + string left = string.Empty, center = string.Empty, right = string.Empty; + if (string.IsNullOrEmpty(text)) { + return (left, center, right); + } + + int i = 0; + while (i < text.Length) { + char ch = text[i++]; + if (ch != '&' || i >= text.Length) { + continue; + } + + char section = text[i++]; + if (section != 'L' && section != 'C' && section != 'R') { + continue; + } + + var builder = new StringBuilder(); + while (i < text.Length) { + if (text[i] == '&' && i + 1 < text.Length) { + char next = text[i + 1]; + if (next == 'L' || next == 'C' || next == 'R') { + break; + } + } + + builder.Append(text[i++]); + } + + string value = builder.ToString(); + if (section == 'L') { + left = value; + } else if (section == 'C') { + center = value; + } else { + right = value; + } + } + + return (left, center, right); + } + + private static string? BuildHeaderFooterSections(string? left, string? center, string? right) { + var builder = new StringBuilder(); + if (!string.IsNullOrEmpty(left)) builder.Append("&L").Append(EscapeHeaderFooter(left)); + if (!string.IsNullOrEmpty(center)) builder.Append("&C").Append(EscapeHeaderFooter(center)); + if (!string.IsNullOrEmpty(right)) builder.Append("&R").Append(EscapeHeaderFooter(right)); + return builder.Length == 0 ? null : builder.ToString(); + } + /// /// Adds an image to the worksheet header at the given position. This will also ensure the header text /// contains the picture placeholder (&G) in the corresponding section. Subsequent calls replace any @@ -249,7 +573,8 @@ bool IsTokenStarter(char c) { case 'D': case 'T': case 'A': - case 'F': // page, pages, date, time, sheet, file + case 'F': + case 'Z': // page, pages, date, time, sheet, file, path case 'G': // picture placeholder case 'K': // color: &Krrggbb case 'B': diff --git a/OfficeIMO.Excel/ExcelSheet.Hyperlinks.cs b/OfficeIMO.Excel/ExcelSheet.Hyperlinks.cs index a0316c5d9..cd1b486c2 100644 --- a/OfficeIMO.Excel/ExcelSheet.Hyperlinks.cs +++ b/OfficeIMO.Excel/ExcelSheet.Hyperlinks.cs @@ -47,6 +47,49 @@ public void SetHyperlink(int row, int column, string url, string? display = null /// sheet.SetHyperlink(2, 1, "https://example.org", display: "Example", style: true); /// + /// + /// Gets worksheet hyperlinks keyed by their A1 cell or range reference. + /// + public IReadOnlyDictionary GetHyperlinks() { + var ws = WorksheetRoot; + var hyperlinks = ws.Elements().FirstOrDefault(); + if (hyperlinks == null) { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var externalRelationships = _worksheetPart.HyperlinkRelationships + .Where(relationship => !string.IsNullOrWhiteSpace(relationship.Id)) + .ToDictionary(relationship => relationship.Id!, relationship => relationship, StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var hyperlink in hyperlinks.Elements()) { + string? reference = hyperlink.Reference?.Value; + if (string.IsNullOrWhiteSpace(reference)) { + continue; + } + + string? target = null; + bool isExternal = false; + string? relationshipId = hyperlink.Id?.Value; + if (!string.IsNullOrWhiteSpace(relationshipId) && externalRelationships.TryGetValue(relationshipId!, out var relationship)) { + target = relationship.Uri?.OriginalString; + isExternal = true; + } else if (!string.IsNullOrWhiteSpace(hyperlink.Location?.Value)) { + target = hyperlink.Location!.Value!; + } + + if (string.IsNullOrWhiteSpace(target)) { + continue; + } + + result[reference!] = new ExcelHyperlinkSnapshot { + IsExternal = isExternal, + Target = target! + }; + } + + return result; + } + /// /// Sets an external hyperlink using an A1 reference (e.g., "B5"). /// diff --git a/OfficeIMO.Excel/ExcelSheet.MergedRanges.cs b/OfficeIMO.Excel/ExcelSheet.MergedRanges.cs new file mode 100644 index 000000000..b2b0f23eb --- /dev/null +++ b/OfficeIMO.Excel/ExcelSheet.MergedRanges.cs @@ -0,0 +1,44 @@ +using DocumentFormat.OpenXml.Spreadsheet; + +namespace OfficeIMO.Excel { + public partial class ExcelSheet { + /// + /// Gets worksheet merged ranges as reusable one-based A1 metadata. + /// + public IReadOnlyList GetMergedRanges() { + MergeCells? merges = WorksheetRoot.GetFirstChild(); + if (merges == null) { + return Array.Empty(); + } + + var ranges = new List(); + foreach (MergeCell merge in merges.Elements()) { + string? reference = merge.Reference?.Value; + if (string.IsNullOrWhiteSpace(reference)) { + continue; + } + + string normalized = NormalizeMergeRangeReference(reference!); + if (!A1.TryParseRange(normalized, out int firstRow, out int firstColumn, out int lastRow, out int lastColumn)) { + continue; + } + + ranges.Add(new ExcelMergedRangeSnapshot { + A1Range = normalized, + StartRow = firstRow, + StartColumn = firstColumn, + EndRow = lastRow, + EndColumn = lastColumn + }); + } + + return ranges.AsReadOnly(); + } + + private static string NormalizeMergeRangeReference(string reference) { + int sheetSeparator = reference.LastIndexOf('!'); + string range = sheetSeparator >= 0 ? reference.Substring(sheetSeparator + 1) : reference; + return range.Replace("$", string.Empty); + } + } +} diff --git a/OfficeIMO.Excel/ExcelSheet.PrintSettings.cs b/OfficeIMO.Excel/ExcelSheet.PrintSettings.cs new file mode 100644 index 000000000..983175440 --- /dev/null +++ b/OfficeIMO.Excel/ExcelSheet.PrintSettings.cs @@ -0,0 +1,316 @@ +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Spreadsheet; + +namespace OfficeIMO.Excel { + /// + /// Readable worksheet page setup values relevant to print/export pipelines. + /// + public sealed class ExcelSheetPageSetup { + /// + /// Worksheet page orientation when present. + /// + public ExcelPageOrientation? Orientation { get; internal set; } + + /// + /// Worksheet print margins in inches when present. + /// + public ExcelSheetPageMargins? Margins { get; internal set; } + + /// + /// Number of pages to fit horizontally, when configured. + /// + public uint? FitToWidth { get; internal set; } + + /// + /// Number of pages to fit vertically, when configured. + /// + public uint? FitToHeight { get; internal set; } + + /// + /// Manual worksheet print scale percentage, when configured. + /// + public uint? Scale { get; internal set; } + } + + /// + /// Worksheet page margins in inches. + /// + public sealed class ExcelSheetPageMargins { + /// Left margin in inches. + public double Left { get; internal set; } + /// Right margin in inches. + public double Right { get; internal set; } + /// Top margin in inches. + public double Top { get; internal set; } + /// Bottom margin in inches. + public double Bottom { get; internal set; } + /// Header margin in inches. + public double Header { get; internal set; } + /// Footer margin in inches. + public double Footer { get; internal set; } + } + + /// + /// Worksheet print title rows and columns. + /// + public sealed class ExcelPrintTitles { + /// First repeated row, one-based. + public int? FirstRow { get; internal set; } + /// Last repeated row, one-based. + public int? LastRow { get; internal set; } + /// First repeated column, one-based. + public int? FirstColumn { get; internal set; } + /// Last repeated column, one-based. + public int? LastColumn { get; internal set; } + /// Whether repeated rows are configured. + public bool HasRows => FirstRow.HasValue && LastRow.HasValue; + /// Whether repeated columns are configured. + public bool HasColumns => FirstColumn.HasValue && LastColumn.HasValue; + } + + public partial class ExcelSheet { + /// + /// Reads worksheet page setup values used by print/export pipelines. + /// + public ExcelSheetPageSetup GetPageSetup() { + var result = new ExcelSheetPageSetup(); + PageSetup? pageSetup = WorksheetRoot.GetFirstChild(); + if (pageSetup?.Orientation?.Value == OrientationValues.Landscape) { + result.Orientation = ExcelPageOrientation.Landscape; + } else if (pageSetup?.Orientation?.Value == OrientationValues.Portrait) { + result.Orientation = ExcelPageOrientation.Portrait; + } + + if (pageSetup != null) { + result.FitToWidth = pageSetup.FitToWidth?.Value; + result.FitToHeight = pageSetup.FitToHeight?.Value; + result.Scale = pageSetup.Scale?.Value; + } + + PageMargins? margins = WorksheetRoot.GetFirstChild(); + if (margins != null) { + result.Margins = new ExcelSheetPageMargins { + Left = margins.Left?.Value ?? 0D, + Right = margins.Right?.Value ?? 0D, + Top = margins.Top?.Value ?? 0D, + Bottom = margins.Bottom?.Value ?? 0D, + Header = margins.Header?.Value ?? 0D, + Footer = margins.Footer?.Value ?? 0D + }; + } + + return result; + } + + /// + /// Gets the worksheet print area range, or null when no print area is configured. + /// + public string? GetPrintArea() { + return _excelDocument.GetNamedRange("_xlnm.Print_Area", this); + } + + /// + /// Gets worksheet print title rows and columns. + /// + public ExcelPrintTitles GetPrintTitles() { + var titles = new ExcelPrintTitles(); + string? definedName = _excelDocument.GetNamedRange("_xlnm.Print_Titles", this); + if (string.IsNullOrWhiteSpace(definedName)) { + return titles; + } + + foreach (string part in SplitDefinedNameParts(definedName!)) { + string reference = StripSheetPrefix(part).Replace("$", string.Empty); + int separator = reference.IndexOf(':'); + if (separator <= 0 || separator >= reference.Length - 1) { + continue; + } + + string start = reference.Substring(0, separator); + string end = reference.Substring(separator + 1); + if (int.TryParse(start, out int firstRow) && + int.TryParse(end, out int lastRow) && + firstRow > 0 && + lastRow >= firstRow) { + titles.FirstRow = firstRow; + titles.LastRow = lastRow; + continue; + } + + int firstColumn = A1.ColumnLettersToIndex(start); + int lastColumn = A1.ColumnLettersToIndex(end); + if (firstColumn > 0 && lastColumn >= firstColumn) { + titles.FirstColumn = firstColumn; + titles.LastColumn = lastColumn; + } + } + + return titles; + } + + /// + /// Adds a manual worksheet row page break after the specified one-based row. + /// + public void AddManualRowPageBreak(int row, bool save = true) { + if (row <= 0) { + throw new ArgumentOutOfRangeException(nameof(row), "Row page break must be one-based and positive."); + } + + WriteLock(() => { + RowBreaks breaks = GetOrCreateRowBreaks(); + AddManualPageBreak(breaks, (uint)row, 16383U); + if (save) { + WorksheetRoot.Save(); + } + }); + } + + /// + /// Adds a manual worksheet column page break after the specified one-based column. + /// + public void AddManualColumnPageBreak(int column, bool save = true) { + if (column <= 0) { + throw new ArgumentOutOfRangeException(nameof(column), "Column page break must be one-based and positive."); + } + + WriteLock(() => { + ColumnBreaks breaks = GetOrCreateColumnBreaks(); + AddManualPageBreak(breaks, (uint)column, 1048575U); + if (save) { + WorksheetRoot.Save(); + } + }); + } + + /// + /// Gets one-based worksheet rows that have a manual page break after them. + /// + public IReadOnlyList GetManualRowPageBreaks() { + return GetManualPageBreaks(WorksheetRoot.GetFirstChild()); + } + + /// + /// Gets one-based worksheet columns that have a manual page break after them. + /// + public IReadOnlyList GetManualColumnPageBreaks() { + return GetManualPageBreaks(WorksheetRoot.GetFirstChild()); + } + + private static IEnumerable SplitDefinedNameParts(string text) { + var parts = new List(); + int start = 0; + bool inQuote = false; + for (int i = 0; i < text.Length; i++) { + char ch = text[i]; + if (ch == '\'') { + if (inQuote && i + 1 < text.Length && text[i + 1] == '\'') { + i++; + } else { + inQuote = !inQuote; + } + } else if (ch == ',' && !inQuote) { + AddPart(text, start, i - start, parts); + start = i + 1; + } + } + + AddPart(text, start, text.Length - start, parts); + return parts; + } + + private static void AddPart(string text, int start, int length, List parts) { + string part = text.Substring(start, length).Trim(); + if (part.Length > 0) { + parts.Add(part); + } + } + + private static string StripSheetPrefix(string reference) { + int separator = reference.LastIndexOf('!'); + return separator >= 0 ? reference.Substring(separator + 1) : reference; + } + + private RowBreaks GetOrCreateRowBreaks() { + RowBreaks? breaks = WorksheetRoot.GetFirstChild(); + if (breaks != null) { + return breaks; + } + + breaks = new RowBreaks(); + ColumnBreaks? columnBreaks = WorksheetRoot.GetFirstChild(); + if (columnBreaks != null) { + WorksheetRoot.InsertBefore(breaks, columnBreaks); + } else { + InsertAfterPrintSetupElement(breaks); + } + + return breaks; + } + + private ColumnBreaks GetOrCreateColumnBreaks() { + ColumnBreaks? breaks = WorksheetRoot.GetFirstChild(); + if (breaks != null) { + return breaks; + } + + breaks = new ColumnBreaks(); + InsertAfterPrintSetupElement(breaks); + return breaks; + } + + private void InsertAfterPrintSetupElement(OpenXmlElement element) { + OpenXmlElement? previous = WorksheetRoot.GetFirstChild(); + previous ??= WorksheetRoot.GetFirstChild(); + previous ??= WorksheetRoot.GetFirstChild(); + previous ??= WorksheetRoot.GetFirstChild(); + previous ??= WorksheetRoot.GetFirstChild(); + + if (previous != null) { + WorksheetRoot.InsertAfter(element, previous); + } else { + WorksheetRoot.Append(element); + } + } + + private static IReadOnlyList GetManualPageBreaks(OpenXmlCompositeElement? breaks) { + if (breaks == null) { + return Array.Empty(); + } + + return breaks.Elements() + .Where(item => item.ManualPageBreak?.Value == true && item.Id?.Value > 0U) + .Select(item => (int)item.Id!.Value) + .Distinct() + .OrderBy(item => item) + .ToList(); + } + + private static void AddManualPageBreak(OpenXmlCompositeElement breaks, uint id, uint max) { + Break? existing = breaks.Elements().FirstOrDefault(item => item.Id?.Value == id); + if (existing == null) { + breaks.Append(new Break { + Id = id, + Min = 0U, + Max = max, + ManualPageBreak = true + }); + } else { + existing.Min = 0U; + existing.Max = max; + existing.ManualPageBreak = true; + } + + uint count = (uint)breaks.Elements().Count(); + switch (breaks) { + case RowBreaks rowBreaks: + rowBreaks.Count = count; + rowBreaks.ManualBreakCount = count; + break; + case ColumnBreaks columnBreaks: + columnBreaks.Count = count; + columnBreaks.ManualBreakCount = count; + break; + } + } + } +} diff --git a/OfficeIMO.Excel/ExcelSheet.RowDefinitions.cs b/OfficeIMO.Excel/ExcelSheet.RowDefinitions.cs new file mode 100644 index 000000000..96e7d472a --- /dev/null +++ b/OfficeIMO.Excel/ExcelSheet.RowDefinitions.cs @@ -0,0 +1,38 @@ +using DocumentFormat.OpenXml.Spreadsheet; + +namespace OfficeIMO.Excel { + public partial class ExcelSheet { + /// + /// Gets explicit worksheet row definitions such as custom heights and hidden rows. + /// + public IReadOnlyList GetRowDefinitions() { + SheetData? sheetData = WorksheetRoot.GetFirstChild(); + if (sheetData == null) { + return Array.Empty(); + } + + var result = new List(); + foreach (Row row in sheetData.Elements()) { + int index = checked((int)(row.RowIndex?.Value ?? 0U)); + if (index <= 0) { + continue; + } + + bool hidden = row.Hidden?.Value == true; + bool customHeight = row.CustomHeight?.Value == true; + if (!hidden && !customHeight && row.Height == null) { + continue; + } + + result.Add(new ExcelRowSnapshot { + Index = index, + Height = row.Height?.Value, + Hidden = hidden, + CustomHeight = customHeight + }); + } + + return result.AsReadOnly(); + } + } +} diff --git a/OfficeIMO.Excel/ExcelSheet.RuleManagement.cs b/OfficeIMO.Excel/ExcelSheet.RuleManagement.cs index 532c1acf1..7876cd580 100644 --- a/OfficeIMO.Excel/ExcelSheet.RuleManagement.cs +++ b/OfficeIMO.Excel/ExcelSheet.RuleManagement.cs @@ -22,11 +22,16 @@ public IReadOnlyList GetConditionalFormattingRul foreach (var rule in conditional.Elements()) { list.Add(new ExcelConditionalFormattingInfo { Range = range, - Type = rule.Type?.Value.ToString() ?? string.Empty, + Type = ReadConditionalFormatType(rule), Operator = rule.Operator?.Value.ToString(), Priority = (int)(rule.Priority?.Value ?? 0), StopIfTrue = rule.StopIfTrue?.Value ?? false, - Formulas = rule.Elements().Select(f => f.Text ?? string.Empty).ToArray() + Formulas = rule.Elements().Select(f => f.Text ?? string.Empty).ToArray(), + ColorScaleColors = ReadColorScaleColors(rule), + DataBarColor = ReadDataBarColor(rule), + IconSet = ReadIconSetName(rule), + IconSetShowValue = ReadIconSetShowValue(rule), + IconSetReverse = ReadIconSetReverse(rule) }); } } @@ -34,6 +39,77 @@ public IReadOnlyList GetConditionalFormattingRul return list; } + private static string ReadConditionalFormatType(ConditionalFormattingRule rule) { + if (rule.Type == null) { + return string.Empty; + } + + ConditionalFormatValues value = rule.Type.Value; + if (value == ConditionalFormatValues.CellIs) return nameof(ConditionalFormatValues.CellIs); + if (value == ConditionalFormatValues.Expression) return nameof(ConditionalFormatValues.Expression); + if (value == ConditionalFormatValues.ColorScale) return nameof(ConditionalFormatValues.ColorScale); + if (value == ConditionalFormatValues.DataBar) return nameof(ConditionalFormatValues.DataBar); + if (value == ConditionalFormatValues.IconSet) return nameof(ConditionalFormatValues.IconSet); + if (value == ConditionalFormatValues.Top10) return nameof(ConditionalFormatValues.Top10); + if (value == ConditionalFormatValues.UniqueValues) return nameof(ConditionalFormatValues.UniqueValues); + if (value == ConditionalFormatValues.DuplicateValues) return nameof(ConditionalFormatValues.DuplicateValues); + if (value == ConditionalFormatValues.ContainsText) return nameof(ConditionalFormatValues.ContainsText); + if (value == ConditionalFormatValues.NotContainsText) return nameof(ConditionalFormatValues.NotContainsText); + if (value == ConditionalFormatValues.BeginsWith) return nameof(ConditionalFormatValues.BeginsWith); + if (value == ConditionalFormatValues.EndsWith) return nameof(ConditionalFormatValues.EndsWith); + if (value == ConditionalFormatValues.ContainsBlanks) return nameof(ConditionalFormatValues.ContainsBlanks); + if (value == ConditionalFormatValues.NotContainsBlanks) return nameof(ConditionalFormatValues.NotContainsBlanks); + if (value == ConditionalFormatValues.ContainsErrors) return nameof(ConditionalFormatValues.ContainsErrors); + if (value == ConditionalFormatValues.NotContainsErrors) return nameof(ConditionalFormatValues.NotContainsErrors); + if (value == ConditionalFormatValues.TimePeriod) return nameof(ConditionalFormatValues.TimePeriod); + if (value == ConditionalFormatValues.AboveAverage) return nameof(ConditionalFormatValues.AboveAverage); + + return rule.Type.InnerText ?? string.Empty; + } + + private static IReadOnlyList ReadColorScaleColors(ConditionalFormattingRule rule) { + ColorScale? colorScale = rule.GetFirstChild(); + if (colorScale == null) { + return Array.Empty(); + } + + return colorScale.Elements() + .Select(color => color.Rgb?.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!) + .ToArray(); + } + + private static string? ReadDataBarColor(ConditionalFormattingRule rule) { + DataBar? dataBar = rule.GetFirstChild(); + if (dataBar == null) { + return null; + } + + return dataBar.Elements() + .Select(color => color.Rgb?.Value) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + } + + private static string? ReadIconSetName(ConditionalFormattingRule rule) { + IconSet? iconSet = rule.GetFirstChild(); + if (iconSet?.IconSetValue?.Value == IconSetValues.ThreeTrafficLights1) { + return nameof(IconSetValues.ThreeTrafficLights1); + } + + return iconSet?.IconSetValue?.InnerText; + } + + private static bool ReadIconSetShowValue(ConditionalFormattingRule rule) { + IconSet? iconSet = rule.GetFirstChild(); + return iconSet?.ShowValue?.Value ?? true; + } + + private static bool ReadIconSetReverse(ConditionalFormattingRule rule) { + IconSet? iconSet = rule.GetFirstChild(); + return iconSet?.Reverse?.Value ?? false; + } + /// /// Clears conditional formatting rules, optionally restricted to a range. /// diff --git a/OfficeIMO.Excel/ExcelSheet.Visibility.cs b/OfficeIMO.Excel/ExcelSheet.Visibility.cs index 955952ee1..a28c2d2a5 100644 --- a/OfficeIMO.Excel/ExcelSheet.Visibility.cs +++ b/OfficeIMO.Excel/ExcelSheet.Visibility.cs @@ -2,6 +2,11 @@ namespace OfficeIMO.Excel { public partial class ExcelSheet { + /// + /// Gets whether the worksheet is hidden or very hidden in the workbook. + /// + public bool Hidden => _sheet.State?.Value == SheetStateValues.Hidden || _sheet.State?.Value == SheetStateValues.VeryHidden; + /// /// Hides or shows the worksheet in the workbook. /// diff --git a/OfficeIMO.Pdf/Compose/PdfElementCompose.cs b/OfficeIMO.Pdf/Compose/PdfElementCompose.cs index 96b97f82e..68ca97e5f 100644 --- a/OfficeIMO.Pdf/Compose/PdfElementCompose.cs +++ b/OfficeIMO.Pdf/Compose/PdfElementCompose.cs @@ -81,13 +81,15 @@ public class PdfElementCompose { /// Adds a named bookmark at the current nested element flow position. public PdfElementCompose Bookmark(string name) { _doc.Bookmark(name); return this; } /// Adds a simple AcroForm text field at the current nested element flow position. - public PdfElementCompose TextField(string name, double width = 180, double height = 22, string value = "", PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6) { _doc.TextField(name, width, height, value, align, fontSize, spacingBefore, spacingAfter); return this; } + public PdfElementCompose TextField(string name, double width = 180, double height = 22, string value = "", PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { _doc.TextField(name, width, height, value, align, fontSize, spacingBefore, spacingAfter, style); return this; } /// Adds a simple AcroForm check box at the current nested element flow position. - public PdfElementCompose CheckBox(string name, bool isChecked = false, double size = 14, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, string checkedValueName = "Yes") { _doc.CheckBox(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName); return this; } + public PdfElementCompose CheckBox(string name, bool isChecked = false, double size = 14, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, string checkedValueName = "Yes", PdfFormFieldStyle? style = null) { _doc.CheckBox(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName, style); return this; } /// Adds a simple AcroForm choice field at the current nested element flow position. - public PdfElementCompose ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 180, double height = 22, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, bool isComboBox = true) { _doc.ChoiceField(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox); return this; } + public PdfElementCompose ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 180, double height = 22, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, bool isComboBox = true, PdfFormFieldStyle? style = null) { _doc.ChoiceField(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox, style); return this; } /// Adds a simple AcroForm multi-select choice field at the current nested element flow position. - public PdfElementCompose MultiSelectChoiceField(string name, System.Collections.Generic.IEnumerable options, System.Collections.Generic.IEnumerable? values = null, double width = 180, double height = 72, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6) { _doc.MultiSelectChoiceField(name, options, values, width, height, align, fontSize, spacingBefore, spacingAfter); return this; } + public PdfElementCompose MultiSelectChoiceField(string name, System.Collections.Generic.IEnumerable options, System.Collections.Generic.IEnumerable? values = null, double width = 180, double height = 72, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { _doc.MultiSelectChoiceField(name, options, values, width, height, align, fontSize, spacingBefore, spacingAfter, style); return this; } + /// Adds a simple AcroForm radio button group at the current nested element flow position. + public PdfElementCompose RadioButtonGroup(string name, System.Collections.Generic.IEnumerable options, string? value = null, double size = 14, double gap = 6, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { _doc.RadioButtonGroup(name, options, value, size, gap, align, spacingBefore, spacingAfter, style); return this; } /// Adds a shared OfficeIMO.Drawing shape. public PdfElementCompose Shape(OfficeShape shape, PdfAlign? align = null, double? spacingBefore = null, double? spacingAfter = null, PdfDrawingStyle? style = null, string? linkUri = null, string? linkContents = null) { _doc.Shape(shape, align, spacingBefore, spacingAfter, style, linkUri, linkContents); return this; } /// Adds a shared OfficeIMO.Drawing scene. diff --git a/OfficeIMO.Pdf/Compose/PdfFooterCompose.cs b/OfficeIMO.Pdf/Compose/PdfFooterCompose.cs index 2dad4efbe..6d2395d86 100644 --- a/OfficeIMO.Pdf/Compose/PdfFooterCompose.cs +++ b/OfficeIMO.Pdf/Compose/PdfFooterCompose.cs @@ -1,3 +1,5 @@ +using OfficeIMO.Drawing; + namespace OfficeIMO.Pdf; /// Footer builder (alignment, text, page number tokens). @@ -37,6 +39,43 @@ public PdfFooterCompose EvenPagesZones(string? left, string? center, string? rig _opts.SetEvenPageFooterZonesForCompose(left, center, right); return this; } + + /// Adds an image to the running footer. + public PdfFooterCompose Image(byte[] data, double width, double height, PdfAlign align = PdfAlign.Left, OfficeImageFit fit = OfficeImageFit.Stretch) { + _opts.AddFooterImageForCompose(new PdfHeaderFooterImage(data, width, height, align, fit)); + return this; + } + + /// Adds an image to the page-1-only footer. + public PdfFooterCompose FirstPageImage(byte[] data, double width, double height, PdfAlign align = PdfAlign.Left, OfficeImageFit fit = OfficeImageFit.Stretch) { + _opts.AddFirstPageFooterImageForCompose(new PdfHeaderFooterImage(data, width, height, align, fit)); + return this; + } + + /// Adds an image to the even-page-only footer. + public PdfFooterCompose EvenPagesImage(byte[] data, double width, double height, PdfAlign align = PdfAlign.Left, OfficeImageFit fit = OfficeImageFit.Stretch) { + _opts.AddEvenPageFooterImageForCompose(new PdfHeaderFooterImage(data, width, height, align, fit)); + return this; + } + + /// Adds a shape to the running footer. + public PdfFooterCompose Shape(OfficeShape shape, PdfAlign align = PdfAlign.Left) { + _opts.AddFooterShapeForCompose(new PdfHeaderFooterShape(shape, align)); + return this; + } + + /// Adds a shape to the page-1-only footer. + public PdfFooterCompose FirstPageShape(OfficeShape shape, PdfAlign align = PdfAlign.Left) { + _opts.AddFirstPageFooterShapeForCompose(new PdfHeaderFooterShape(shape, align)); + return this; + } + + /// Adds a shape to the even-page-only footer. + public PdfFooterCompose EvenPagesShape(OfficeShape shape, PdfAlign align = PdfAlign.Left) { + _opts.AddEvenPageFooterShapeForCompose(new PdfHeaderFooterShape(shape, align)); + return this; + } + /// Renders a literal footer text format. Supports {page} and {pages}. public PdfFooterCompose Text(string format) { Guard.NotNull(format, nameof(format)); diff --git a/OfficeIMO.Pdf/Compose/PdfHeaderCompose.cs b/OfficeIMO.Pdf/Compose/PdfHeaderCompose.cs index d301d1e50..2b500b55b 100644 --- a/OfficeIMO.Pdf/Compose/PdfHeaderCompose.cs +++ b/OfficeIMO.Pdf/Compose/PdfHeaderCompose.cs @@ -1,3 +1,5 @@ +using OfficeIMO.Drawing; + namespace OfficeIMO.Pdf; /// Header builder (alignment, text, page number tokens). @@ -37,6 +39,43 @@ public PdfHeaderCompose EvenPagesZones(string? left, string? center, string? rig _opts.SetEvenPageHeaderZonesForCompose(left, center, right); return this; } + + /// Adds an image to the running header. + public PdfHeaderCompose Image(byte[] data, double width, double height, PdfAlign align = PdfAlign.Left, OfficeImageFit fit = OfficeImageFit.Stretch) { + _opts.AddHeaderImageForCompose(new PdfHeaderFooterImage(data, width, height, align, fit)); + return this; + } + + /// Adds an image to the page-1-only header. + public PdfHeaderCompose FirstPageImage(byte[] data, double width, double height, PdfAlign align = PdfAlign.Left, OfficeImageFit fit = OfficeImageFit.Stretch) { + _opts.AddFirstPageHeaderImageForCompose(new PdfHeaderFooterImage(data, width, height, align, fit)); + return this; + } + + /// Adds an image to the even-page-only header. + public PdfHeaderCompose EvenPagesImage(byte[] data, double width, double height, PdfAlign align = PdfAlign.Left, OfficeImageFit fit = OfficeImageFit.Stretch) { + _opts.AddEvenPageHeaderImageForCompose(new PdfHeaderFooterImage(data, width, height, align, fit)); + return this; + } + + /// Adds a shape to the running header. + public PdfHeaderCompose Shape(OfficeShape shape, PdfAlign align = PdfAlign.Left) { + _opts.AddHeaderShapeForCompose(new PdfHeaderFooterShape(shape, align)); + return this; + } + + /// Adds a shape to the page-1-only header. + public PdfHeaderCompose FirstPageShape(OfficeShape shape, PdfAlign align = PdfAlign.Left) { + _opts.AddFirstPageHeaderShapeForCompose(new PdfHeaderFooterShape(shape, align)); + return this; + } + + /// Adds a shape to the even-page-only header. + public PdfHeaderCompose EvenPagesShape(OfficeShape shape, PdfAlign align = PdfAlign.Left) { + _opts.AddEvenPageHeaderShapeForCompose(new PdfHeaderFooterShape(shape, align)); + return this; + } + /// Renders a literal header text format. Supports {page} and {pages}. public PdfHeaderCompose Text(string format) { Guard.NotNull(format, nameof(format)); diff --git a/OfficeIMO.Pdf/Compose/PdfItemCompose.cs b/OfficeIMO.Pdf/Compose/PdfItemCompose.cs index 84063e5ae..b4270942b 100644 --- a/OfficeIMO.Pdf/Compose/PdfItemCompose.cs +++ b/OfficeIMO.Pdf/Compose/PdfItemCompose.cs @@ -30,8 +30,12 @@ public class PdfItemCompose { public PdfItemCompose Paragraph(System.Action build, PdfAlign align = PdfAlign.Left, PdfColor? defaultColor = null, PdfParagraphStyle? style = null) { _doc.Paragraph(build, align, defaultColor, style); return this; } /// Adds a simple bullet list. public PdfItemCompose Bullets(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, PdfListStyle? style = null) { _doc.Bullets(items, align, color, style); return this; } + /// Adds a bullet list whose items can contain rich inline text runs. + public PdfItemCompose RichBullets(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, PdfListStyle? style = null) { _doc.RichBullets(items, align, color, style); return this; } /// Adds a simple numbered list. public PdfItemCompose Numbered(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, int startNumber = 1, PdfListStyle? style = null) { _doc.Numbered(items, align, color, startNumber, style); return this; } + /// Adds a numbered list whose items can contain rich inline text runs. + public PdfItemCompose RichNumbered(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, int startNumber = 1, PdfListStyle? style = null) { _doc.RichNumbered(items, align, color, startNumber, style); return this; } /// Adds a simple text table. /// Sequence of row arrays. /// Table alignment. @@ -61,13 +65,15 @@ public class PdfItemCompose { /// Adds a named bookmark at the current flow position. public PdfItemCompose Bookmark(string name) { _doc.Bookmark(name); return this; } /// Adds a simple AcroForm text field at the current flow position. - public PdfItemCompose TextField(string name, double width = 180, double height = 22, string value = "", PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6) { _doc.TextField(name, width, height, value, align, fontSize, spacingBefore, spacingAfter); return this; } + public PdfItemCompose TextField(string name, double width = 180, double height = 22, string value = "", PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { _doc.TextField(name, width, height, value, align, fontSize, spacingBefore, spacingAfter, style); return this; } /// Adds a simple AcroForm check box at the current flow position. - public PdfItemCompose CheckBox(string name, bool isChecked = false, double size = 14, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, string checkedValueName = "Yes") { _doc.CheckBox(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName); return this; } + public PdfItemCompose CheckBox(string name, bool isChecked = false, double size = 14, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, string checkedValueName = "Yes", PdfFormFieldStyle? style = null) { _doc.CheckBox(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName, style); return this; } /// Adds a simple AcroForm choice field at the current flow position. - public PdfItemCompose ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 180, double height = 22, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, bool isComboBox = true) { _doc.ChoiceField(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox); return this; } + public PdfItemCompose ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 180, double height = 22, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, bool isComboBox = true, PdfFormFieldStyle? style = null) { _doc.ChoiceField(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox, style); return this; } /// Adds a simple AcroForm multi-select choice field at the current flow position. - public PdfItemCompose MultiSelectChoiceField(string name, System.Collections.Generic.IEnumerable options, System.Collections.Generic.IEnumerable? values = null, double width = 180, double height = 72, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6) { _doc.MultiSelectChoiceField(name, options, values, width, height, align, fontSize, spacingBefore, spacingAfter); return this; } + public PdfItemCompose MultiSelectChoiceField(string name, System.Collections.Generic.IEnumerable options, System.Collections.Generic.IEnumerable? values = null, double width = 180, double height = 72, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { _doc.MultiSelectChoiceField(name, options, values, width, height, align, fontSize, spacingBefore, spacingAfter, style); return this; } + /// Adds a simple AcroForm radio button group at the current flow position. + public PdfItemCompose RadioButtonGroup(string name, System.Collections.Generic.IEnumerable options, string? value = null, double size = 14, double gap = 6, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { _doc.RadioButtonGroup(name, options, value, size, gap, align, spacingBefore, spacingAfter, style); return this; } /// Adds a shared OfficeIMO.Drawing shape. public PdfItemCompose Shape(OfficeShape shape, PdfAlign? align = null, double? spacingBefore = null, double? spacingAfter = null, PdfDrawingStyle? style = null, string? linkUri = null, string? linkContents = null) { _doc.Shape(shape, align, spacingBefore, spacingAfter, style, linkUri, linkContents); return this; } /// Adds a shared OfficeIMO.Drawing scene. diff --git a/OfficeIMO.Pdf/Compose/PdfPageCompose.cs b/OfficeIMO.Pdf/Compose/PdfPageCompose.cs index 2002cf2e0..b59689769 100644 --- a/OfficeIMO.Pdf/Compose/PdfPageCompose.cs +++ b/OfficeIMO.Pdf/Compose/PdfPageCompose.cs @@ -31,6 +31,11 @@ public PdfPageCompose Orientation(PdfPageOrientation orientation) { Options.PageHeight = oriented.Height; return this; } + /// Sets or clears the page background color. + public PdfPageCompose Background(PdfColor? color) { + Options.BackgroundColor = color; + return this; + } /// Sets page orientation to portrait while preserving the current page size dimensions. public PdfPageCompose Portrait() => Orientation(PdfPageOrientation.Portrait); /// Sets page orientation to landscape while preserving the current page size dimensions. diff --git a/OfficeIMO.Pdf/Compose/PdfRowColumnCompose.cs b/OfficeIMO.Pdf/Compose/PdfRowColumnCompose.cs index 4432fe6db..a4cdd5b66 100644 --- a/OfficeIMO.Pdf/Compose/PdfRowColumnCompose.cs +++ b/OfficeIMO.Pdf/Compose/PdfRowColumnCompose.cs @@ -15,17 +15,19 @@ public PdfRowColumnCompose Item(System.Action build) { /// Adds invisible vertical space inside the column flow. public PdfRowColumnCompose Spacer(double height) { _col.AddBlock(new SpacerBlock(height)); return this; } /// Adds an H1 heading in the column. - public PdfRowColumnCompose H1(string text, PdfHeadingStyle? style = null, string? linkUri = null, string? linkContents = null) { _col.AddBlock(new HeadingBlock(1, text, PdfAlign.Left, null, linkUri, style, linkContents)); return this; } + public PdfRowColumnCompose H1(string text, PdfHeadingStyle? style = null, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null) { _col.AddBlock(new HeadingBlock(1, text, PdfAlign.Left, null, linkUri, style, linkContents, linkDestinationName)); return this; } /// Adds an H1 heading in the column with explicit alignment and color. - public PdfRowColumnCompose H1(string text, PdfAlign align, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null) { _col.AddBlock(new HeadingBlock(1, text, align, color, linkUri, style, linkContents)); return this; } + public PdfRowColumnCompose H1(string text, PdfAlign align, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null, string? linkDestinationName = null) { _col.AddBlock(new HeadingBlock(1, text, align, color, linkUri, style, linkContents, linkDestinationName)); return this; } /// Adds an H2 heading in the column. - public PdfRowColumnCompose H2(string text, PdfHeadingStyle? style = null, string? linkUri = null, string? linkContents = null) { _col.AddBlock(new HeadingBlock(2, text, PdfAlign.Left, null, linkUri, style, linkContents)); return this; } + public PdfRowColumnCompose H2(string text, PdfHeadingStyle? style = null, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null) { _col.AddBlock(new HeadingBlock(2, text, PdfAlign.Left, null, linkUri, style, linkContents, linkDestinationName)); return this; } /// Adds an H2 heading in the column with explicit alignment and color. - public PdfRowColumnCompose H2(string text, PdfAlign align, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null) { _col.AddBlock(new HeadingBlock(2, text, align, color, linkUri, style, linkContents)); return this; } + public PdfRowColumnCompose H2(string text, PdfAlign align, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null, string? linkDestinationName = null) { _col.AddBlock(new HeadingBlock(2, text, align, color, linkUri, style, linkContents, linkDestinationName)); return this; } /// Adds an H3 heading in the column. - public PdfRowColumnCompose H3(string text, PdfHeadingStyle? style = null, string? linkUri = null, string? linkContents = null) { _col.AddBlock(new HeadingBlock(3, text, PdfAlign.Left, null, linkUri, style, linkContents)); return this; } + public PdfRowColumnCompose H3(string text, PdfHeadingStyle? style = null, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null) { _col.AddBlock(new HeadingBlock(3, text, PdfAlign.Left, null, linkUri, style, linkContents, linkDestinationName)); return this; } /// Adds an H3 heading in the column with explicit alignment and color. - public PdfRowColumnCompose H3(string text, PdfAlign align, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null) { _col.AddBlock(new HeadingBlock(3, text, align, color, linkUri, style, linkContents)); return this; } + public PdfRowColumnCompose H3(string text, PdfAlign align, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null, string? linkDestinationName = null) { _col.AddBlock(new HeadingBlock(3, text, align, color, linkUri, style, linkContents, linkDestinationName)); return this; } + /// Adds a page break in the column flow. + public PdfRowColumnCompose PageBreak() { _col.AddBlock(new PageBreakBlock()); return this; } /// Adds a paragraph built from styled runs to the column. /// Paragraph content builder. /// Paragraph alignment. @@ -43,11 +45,21 @@ public PdfRowColumnCompose Bullets(System.Collections.Generic.IEnumerableAdds a bullet list whose items can contain rich inline text runs in the column. + public PdfRowColumnCompose RichBullets(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, PdfListStyle? style = null) { + _col.AddBlock(new BulletListBlock(items, align, color, style)); + return this; + } /// Adds a simple numbered list in the column. public PdfRowColumnCompose Numbered(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, int startNumber = 1, PdfListStyle? style = null) { _col.AddBlock(new NumberedListBlock(items, align, color, startNumber, style)); return this; } + /// Adds a numbered list whose items can contain rich inline text runs in the column. + public PdfRowColumnCompose RichNumbered(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, int startNumber = 1, PdfListStyle? style = null) { + _col.AddBlock(new NumberedListBlock(items, align, color, startNumber, style)); + return this; + } /// Adds a paragraph inside a styled panel in the column. public PdfRowColumnCompose PanelParagraph(System.Action build, PanelStyle? style = null, PdfAlign align = PdfAlign.Left, PdfColor? defaultColor = null) { Guard.NotNull(build, nameof(build)); @@ -79,23 +91,28 @@ public PdfRowColumnCompose HR(double? thickness = null, PdfColor? color = null, /// Adds a named bookmark at the current column flow position. public PdfRowColumnCompose Bookmark(string name) { _col.AddBlock(new BookmarkBlock(name)); return this; } /// Adds a simple AcroForm text field in the column. - public PdfRowColumnCompose TextField(string name, double width = 180, double height = 22, string value = "", PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6) { - _col.AddBlock(new TextFieldBlock(name, width, height, value, align, fontSize, spacingBefore, spacingAfter)); + public PdfRowColumnCompose TextField(string name, double width = 180, double height = 22, string value = "", PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { + _col.AddBlock(new TextFieldBlock(name, width, height, value, align, fontSize, spacingBefore, spacingAfter, style)); return this; } /// Adds a simple AcroForm check box in the column. - public PdfRowColumnCompose CheckBox(string name, bool isChecked = false, double size = 14, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, string checkedValueName = "Yes") { - _col.AddBlock(new CheckBoxBlock(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName)); + public PdfRowColumnCompose CheckBox(string name, bool isChecked = false, double size = 14, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, string checkedValueName = "Yes", PdfFormFieldStyle? style = null) { + _col.AddBlock(new CheckBoxBlock(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName, style)); return this; } /// Adds a simple AcroForm choice field in the column. - public PdfRowColumnCompose ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 180, double height = 22, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, bool isComboBox = true) { - _col.AddBlock(new ChoiceFieldBlock(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox)); + public PdfRowColumnCompose ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 180, double height = 22, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, bool isComboBox = true, PdfFormFieldStyle? style = null) { + _col.AddBlock(new ChoiceFieldBlock(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox, style)); return this; } /// Adds a simple AcroForm multi-select choice field in the column. - public PdfRowColumnCompose MultiSelectChoiceField(string name, System.Collections.Generic.IEnumerable options, System.Collections.Generic.IEnumerable? values = null, double width = 180, double height = 72, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6) { - _col.AddBlock(new ChoiceFieldBlock(name, options, values, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox: false, allowsMultipleSelection: true)); + public PdfRowColumnCompose MultiSelectChoiceField(string name, System.Collections.Generic.IEnumerable options, System.Collections.Generic.IEnumerable? values = null, double width = 180, double height = 72, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { + _col.AddBlock(new ChoiceFieldBlock(name, options, values, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox: false, allowsMultipleSelection: true, style)); + return this; + } + /// Adds a simple AcroForm radio button group in the column. + public PdfRowColumnCompose RadioButtonGroup(string name, System.Collections.Generic.IEnumerable options, string? value = null, double size = 14, double gap = 6, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { + _col.AddBlock(new RadioButtonGroupBlock(name, options, value, size, gap, align, spacingBefore, spacingAfter, style)); return this; } /// Adds a shared OfficeIMO.Drawing shape in the column. diff --git a/OfficeIMO.Pdf/Compose/PdfRowCompose.cs b/OfficeIMO.Pdf/Compose/PdfRowCompose.cs index 20485fe80..664d8a695 100644 --- a/OfficeIMO.Pdf/Compose/PdfRowCompose.cs +++ b/OfficeIMO.Pdf/Compose/PdfRowCompose.cs @@ -24,6 +24,15 @@ public PdfRowCompose Style(PdfRowStyle style) { return this; } + /// Draws a vertical separator line between columns in this row. + public PdfRowCompose ColumnSeparator(PdfColor color, double width = 0.5D) { + var style = _row.Style ?? new PdfRowStyle(); + style.ColumnSeparatorColor = color; + style.ColumnSeparatorWidth = width; + _row.SetStyle(style); + return this; + } + /// Adds a column with the given width percentage. public PdfRowCompose Column(double widthPercent, System.Action build) { Guard.NotNull(build, nameof(build)); diff --git a/OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs b/OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs index e6572a925..721930342 100644 --- a/OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs +++ b/OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs @@ -4,22 +4,22 @@ namespace OfficeIMO.Pdf; public sealed partial class PdfDoc { /// Adds a level-1 heading. - public PdfDoc H1(string text, PdfAlign align = PdfAlign.Left, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null) { + public PdfDoc H1(string text, PdfAlign align = PdfAlign.Left, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null, string? linkDestinationName = null) { Guard.NotNullOrWhiteSpace(text, nameof(text)); Guard.OptionalAbsoluteUri(linkUri, nameof(linkUri)); - AddBlock(new HeadingBlock(1, text, align, color, linkUri, style, linkContents)); return this; + AddBlock(new HeadingBlock(1, text, align, color, linkUri, style, linkContents, linkDestinationName)); return this; } /// Adds a level-2 heading. - public PdfDoc H2(string text, PdfAlign align = PdfAlign.Left, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null) { + public PdfDoc H2(string text, PdfAlign align = PdfAlign.Left, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null, string? linkDestinationName = null) { Guard.NotNullOrWhiteSpace(text, nameof(text)); Guard.OptionalAbsoluteUri(linkUri, nameof(linkUri)); - AddBlock(new HeadingBlock(2, text, align, color, linkUri, style, linkContents)); return this; + AddBlock(new HeadingBlock(2, text, align, color, linkUri, style, linkContents, linkDestinationName)); return this; } /// Adds a level-3 heading. - public PdfDoc H3(string text, PdfAlign align = PdfAlign.Left, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null) { + public PdfDoc H3(string text, PdfAlign align = PdfAlign.Left, PdfColor? color = null, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null, string? linkDestinationName = null) { Guard.NotNullOrWhiteSpace(text, nameof(text)); Guard.OptionalAbsoluteUri(linkUri, nameof(linkUri)); - AddBlock(new HeadingBlock(3, text, align, color, linkUri, style, linkContents)); return this; + AddBlock(new HeadingBlock(3, text, align, color, linkUri, style, linkContents, linkDestinationName)); return this; } /// Inserts a page break. @@ -61,6 +61,12 @@ public PdfDoc Orientation(PdfPageOrientation orientation) { return this; } + /// Sets or clears the document-wide default page background color. + public PdfDoc Background(PdfColor? color) { + _options.BackgroundColor = color; + return this; + } + /// Sets the document-wide default page orientation to portrait. public PdfDoc Portrait() => Orientation(PdfPageOrientation.Portrait); @@ -169,6 +175,13 @@ public PdfDoc Bullets(System.Collections.Generic.IEnumerable items, PdfA return this; } + /// Adds a bullet list whose items can contain rich inline text runs. + public PdfDoc RichBullets(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, PdfListStyle? style = null) { + Guard.NotNull(items, nameof(items)); + AddBlock(new BulletListBlock(items, align, color, style)); + return this; + } + /// Adds a simple numbered list. public PdfDoc Numbered(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, int startNumber = 1, PdfListStyle? style = null) { Guard.NotNull(items, nameof(items)); @@ -176,6 +189,13 @@ public PdfDoc Numbered(System.Collections.Generic.IEnumerable items, Pdf return this; } + /// Adds a numbered list whose items can contain rich inline text runs. + public PdfDoc RichNumbered(System.Collections.Generic.IEnumerable items, PdfAlign align = PdfAlign.Left, PdfColor? color = null, int startNumber = 1, PdfListStyle? style = null) { + Guard.NotNull(items, nameof(items)); + AddBlock(new NumberedListBlock(items, align, color, startNumber, style)); + return this; + } + /// Sets the document-wide default style for bullet and numbered lists. public PdfDoc DefaultListStyle(PdfListStyle style) { Guard.NotNull(style, nameof(style)); @@ -254,26 +274,32 @@ public PdfDoc Bookmark(string name) { } /// Adds a simple AcroForm text field at the current flow position. - public PdfDoc TextField(string name, double width = 180, double height = 22, string value = "", PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6) { - AddBlock(new TextFieldBlock(name, width, height, value, align, fontSize, spacingBefore, spacingAfter)); + public PdfDoc TextField(string name, double width = 180, double height = 22, string value = "", PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { + AddBlock(new TextFieldBlock(name, width, height, value, align, fontSize, spacingBefore, spacingAfter, style)); return this; } /// Adds a simple AcroForm check box at the current flow position. - public PdfDoc CheckBox(string name, bool isChecked = false, double size = 14, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, string checkedValueName = "Yes") { - AddBlock(new CheckBoxBlock(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName)); + public PdfDoc CheckBox(string name, bool isChecked = false, double size = 14, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, string checkedValueName = "Yes", PdfFormFieldStyle? style = null) { + AddBlock(new CheckBoxBlock(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName, style)); return this; } /// Adds a simple AcroForm choice field at the current flow position. - public PdfDoc ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 180, double height = 22, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, bool isComboBox = true) { - AddBlock(new ChoiceFieldBlock(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox)); + public PdfDoc ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 180, double height = 22, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, bool isComboBox = true, PdfFormFieldStyle? style = null) { + AddBlock(new ChoiceFieldBlock(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox, style)); return this; } /// Adds a simple AcroForm multi-select choice field at the current flow position. - public PdfDoc MultiSelectChoiceField(string name, System.Collections.Generic.IEnumerable options, System.Collections.Generic.IEnumerable? values = null, double width = 180, double height = 72, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6) { - AddBlock(new ChoiceFieldBlock(name, options, values, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox: false, allowsMultipleSelection: true)); + public PdfDoc MultiSelectChoiceField(string name, System.Collections.Generic.IEnumerable options, System.Collections.Generic.IEnumerable? values = null, double width = 180, double height = 72, PdfAlign align = PdfAlign.Left, double fontSize = 10, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { + AddBlock(new ChoiceFieldBlock(name, options, values, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox: false, allowsMultipleSelection: true, style)); + return this; + } + + /// Adds a simple AcroForm radio button group at the current flow position. + public PdfDoc RadioButtonGroup(string name, System.Collections.Generic.IEnumerable options, string? value = null, double size = 14, double gap = 6, PdfAlign align = PdfAlign.Left, double spacingBefore = 0, double spacingAfter = 6, PdfFormFieldStyle? style = null) { + AddBlock(new RadioButtonGroupBlock(name, options, value, size, gap, align, spacingBefore, spacingAfter, style)); return this; } diff --git a/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs b/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs index cc5883290..cabacfa91 100644 --- a/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs +++ b/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs @@ -6,6 +6,8 @@ namespace OfficeIMO.Pdf; public static class PdfFormFiller { private const string UnsupportedFlattenWidgetMessage = "Only simple text, choice, and button AcroForm widgets with rectangles are supported for flattening by OfficeIMO.Pdf yet."; private const string UnsupportedFlattenAnnotationMessage = "Only simple text, choice, and button AcroForm widgets referenced from page annotations are supported for flattening by OfficeIMO.Pdf yet."; + private static readonly char[] DefaultAppearanceSeparators = { ' ', '\t', '\r', '\n' }; + private const int RadioButtonFlag = 32768; private const int EditableChoiceFlag = 262144; private const int MultiSelectChoiceFlag = 2097152; @@ -692,7 +694,7 @@ private static void SetFieldValue(Dictionary objects, Pd string name = string.IsNullOrEmpty(firstValue) ? "Off" : firstValue; field.Items["V"] = new PdfName(name); field.Items["AS"] = new PdfName(name); - SetWidgetAppearanceStates(objects, field, name, new HashSet(), ref nextObjectNumber); + SetWidgetAppearanceStates(objects, field, name, (fieldFlags & RadioButtonFlag) != 0, new HashSet(), ref nextObjectNumber); return; } @@ -736,10 +738,11 @@ private static int ReadFieldFlags(Dictionary objects, Pd return ResolveObject(objects, optionsObject) as PdfArray; } - private static void SetWidgetAppearanceStates(Dictionary objects, PdfDictionary field, string name, HashSet visited, ref int nextObjectNumber) { + private static void SetWidgetAppearanceStates(Dictionary objects, PdfDictionary field, string name, bool isRadioButtonGroup, HashSet visited, ref int nextObjectNumber) { if (IsWidget(field)) { - field.Items["AS"] = new PdfName(name); - EnsureButtonWidgetAppearances(objects, field, name, ref nextObjectNumber); + string appearanceState = isRadioButtonGroup && !HasButtonNormalAppearanceState(objects, field, name) ? "Off" : name; + field.Items["AS"] = new PdfName(appearanceState); + EnsureButtonWidgetAppearances(objects, field, appearanceState, ref nextObjectNumber); } if (!field.Items.TryGetValue("Kids", out var kidsObject) || @@ -754,12 +757,21 @@ private static void SetWidgetAppearanceStates(Dictionary } if (ResolveObject(objects, kidObject) is PdfDictionary kid) { - kid.Items["AS"] = new PdfName(name); - SetWidgetAppearanceStates(objects, kid, name, visited, ref nextObjectNumber); + SetWidgetAppearanceStates(objects, kid, name, isRadioButtonGroup, visited, ref nextObjectNumber); } } } + private static bool HasButtonNormalAppearanceState(Dictionary objects, PdfDictionary widget, string stateName) { + if (string.IsNullOrEmpty(stateName)) { + return false; + } + + return TryGetNormalAppearanceObject(objects, widget, out PdfObject? normalAppearance) && + normalAppearance is PdfDictionary appearanceStates && + appearanceStates.Items.ContainsKey(stateName); + } + private static void EnsureButtonWidgetAppearances(Dictionary objects, PdfDictionary widget, string selectedName, ref int nextObjectNumber) { if (!TryReadRect(widget, out double width, out double height)) { return; @@ -768,13 +780,13 @@ private static void EnsureButtonWidgetAppearances(Dictionary objects, PdfDictionary field, string value, HashSet visited, ref int nextObjectNumber) { if (IsWidget(field) && TryReadRect(field, out double width, out double height)) { int appearanceObjectNumber = nextObjectNumber++; - objects[appearanceObjectNumber] = new PdfIndirectObject(appearanceObjectNumber, 0, CreateTextAppearanceStream(value, width, height)); + objects[appearanceObjectNumber] = new PdfIndirectObject(appearanceObjectNumber, 0, CreateTextAppearanceStream(value, width, height, ReadWidgetAppearanceStyle(objects, field))); var appearance = new PdfDictionary(); appearance.Items["N"] = new PdfReference(appearanceObjectNumber, 0); @@ -826,15 +838,9 @@ private static void SetTextWidgetAppearances(Dictionary } } - private static PdfStream CreateTextAppearanceStream(string value, double width, double height) { + private static PdfStream CreateTextAppearanceStream(string value, double width, double height, PdfFormFieldStyle? style = null) { double fontSize = Math.Max(6D, Math.Min(12D, height - 4D)); - double baseline = Math.Max(2D, (height - fontSize) / 2D); - string content = - "q\n" + - "1 1 1 rg 0 0 " + FormatNumber(width) + " " + FormatNumber(height) + " re f\n" + - "0.75 0.75 0.75 RG 0.5 0.5 " + FormatNumber(Math.Max(0D, width - 1D)) + " " + FormatNumber(Math.Max(0D, height - 1D)) + " re S\n" + - "BT /Helv " + FormatNumber(fontSize) + " Tf 0 0 0 rg 2 " + FormatNumber(baseline) + " Td " + PdfSyntaxEscaper.WinAnsiHexString(value) + " Tj ET\n" + - "Q\n"; + string content = PdfAcroFormDictionaryBuilder.BuildTextFieldAppearanceContent(width, height, value, fontSize, style); var dictionary = new PdfDictionary(); dictionary.Items["Type"] = new PdfName("XObject"); @@ -844,30 +850,8 @@ private static PdfStream CreateTextAppearanceStream(string value, double width, return new PdfStream(dictionary, PdfEncoding.Latin1GetBytes(content)); } - private static PdfStream CreateButtonAppearanceStream(double width, double height, bool selected) { - double boxWidth = Math.Max(0D, width - 1D); - double boxHeight = Math.Max(0D, height - 1D); - string content = - "q\n" + - "1 1 1 rg 0 0 " + FormatNumber(width) + " " + FormatNumber(height) + " re f\n" + - "0.75 0.75 0.75 RG 0.5 0.5 " + FormatNumber(boxWidth) + " " + FormatNumber(boxHeight) + " re S\n"; - - if (selected) { - double markLeft = Math.Max(2D, width * 0.2D); - double markMidX = Math.Max(markLeft + 1D, width * 0.42D); - double markRight = Math.Max(markMidX + 1D, width * 0.8D); - double markMidY = Math.Max(2D, height * 0.25D); - double markLeftY = Math.Min(height - 2D, height * 0.52D); - double markRightY = Math.Min(height - 2D, height * 0.78D); - content += - "0 0 0 RG 1.25 w " + - FormatNumber(markLeft) + " " + FormatNumber(markLeftY) + " m " + - FormatNumber(markMidX) + " " + FormatNumber(markMidY) + " l " + - FormatNumber(markRight) + " " + FormatNumber(markRightY) + " l S\n"; - } - - content += "Q\n"; - + private static PdfStream CreateButtonAppearanceStream(double width, double height, bool selected, PdfFormFieldStyle? style = null) { + string content = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceContent(width, height, selected, style); var dictionary = new PdfDictionary(); dictionary.Items["Type"] = new PdfName("XObject"); dictionary.Items["Subtype"] = new PdfName("Form"); @@ -875,6 +859,70 @@ private static PdfStream CreateButtonAppearanceStream(double width, double heigh return new PdfStream(dictionary, PdfEncoding.Latin1GetBytes(content)); } + private static PdfFormFieldStyle ReadWidgetAppearanceStyle(Dictionary objects, PdfDictionary widget) { + var style = new PdfFormFieldStyle(); + if (ResolveDictionary(objects, widget.Items.TryGetValue("MK", out var mkObject) ? mkObject : null) is PdfDictionary mk) { + if (TryReadColor(objects, mk, "BG", out PdfColor backgroundColor)) { + style.BackgroundColor = backgroundColor; + } + + if (TryReadColor(objects, mk, "BC", out PdfColor borderColor)) { + style.BorderColor = borderColor; + } + } + + if (TryReadDefaultAppearanceTextColor(objects, widget, out PdfColor textColor)) { + style.TextColor = textColor; + } + + return style; + } + + private static bool TryReadColor(Dictionary objects, PdfDictionary dictionary, string key, out PdfColor color) { + color = default; + if (!dictionary.Items.TryGetValue(key, out var colorObject) || + ResolveObject(objects, colorObject) is not PdfArray colorArray || + colorArray.Items.Count < 3 || + ResolveObject(objects, colorArray.Items[0]) is not PdfNumber red || + ResolveObject(objects, colorArray.Items[1]) is not PdfNumber green || + ResolveObject(objects, colorArray.Items[2]) is not PdfNumber blue || + red.Value < 0 || red.Value > 1 || + green.Value < 0 || green.Value > 1 || + blue.Value < 0 || blue.Value > 1) { + return false; + } + + color = new PdfColor(red.Value, green.Value, blue.Value); + return true; + } + + private static bool TryReadDefaultAppearanceTextColor(Dictionary objects, PdfDictionary widget, out PdfColor color) { + color = default; + string? defaultAppearance = TryReadText(objects, widget, "DA"); + if (string.IsNullOrWhiteSpace(defaultAppearance)) { + return false; + } + + string[] parts = defaultAppearance!.Split(DefaultAppearanceSeparators, StringSplitOptions.RemoveEmptyEntries); + for (int i = 3; i < parts.Length; i++) { + if (!string.Equals(parts[i], "rg", StringComparison.Ordinal)) { + continue; + } + + if (double.TryParse(parts[i - 3], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double red) && + double.TryParse(parts[i - 2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double green) && + double.TryParse(parts[i - 1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double blue) && + red >= 0 && red <= 1 && + green >= 0 && green <= 1 && + blue >= 0 && blue <= 1) { + color = new PdfColor(red, green, blue); + return true; + } + } + + return false; + } + private static PdfDictionary CreateAppearanceResources() { var font = new PdfDictionary(); font.Items["Type"] = new PdfName("Font"); diff --git a/OfficeIMO.Pdf/Model/BulletListBlock.cs b/OfficeIMO.Pdf/Model/BulletListBlock.cs index 59d642c0f..e95916767 100644 --- a/OfficeIMO.Pdf/Model/BulletListBlock.cs +++ b/OfficeIMO.Pdf/Model/BulletListBlock.cs @@ -2,6 +2,7 @@ namespace OfficeIMO.Pdf; internal sealed class BulletListBlock : IPdfBlock { public System.Collections.Generic.IReadOnlyList Items { get; } + public System.Collections.Generic.IReadOnlyList RichItems { get; } public PdfAlign Align { get; } public PdfColor? Color { get; } public PdfListStyle? Style { get; } @@ -11,8 +12,31 @@ public BulletListBlock(System.Collections.Generic.IEnumerable items, Pdf Align = align; Color = color; Style = style?.Clone(); - var snapshot = new System.Collections.Generic.List(); - snapshot.AddRange(items.Where(item => item is not null)); - Items = snapshot.AsReadOnly(); + var richSnapshot = new System.Collections.Generic.List(); + foreach (string? item in items) { + if (item != null) { + richSnapshot.Add(new PdfListItem(item)); + } + } + + RichItems = richSnapshot.AsReadOnly(); + Items = richSnapshot.Select(item => item.Text).ToList().AsReadOnly(); + } + + public BulletListBlock(System.Collections.Generic.IEnumerable items, PdfAlign align, PdfColor? color, PdfListStyle? style = null) { + Guard.NotNull(items, nameof(items)); + Guard.LeftCenterRightAlign(align, nameof(align), "Bullet list"); + Align = align; + Color = color; + Style = style?.Clone(); + var richSnapshot = new System.Collections.Generic.List(); + foreach (PdfListItem? item in items) { + if (item != null) { + richSnapshot.Add(new PdfListItem(item.Runs, item.BookmarkName, item.Marker)); + } + } + + RichItems = richSnapshot.AsReadOnly(); + Items = richSnapshot.Select(item => item.Text).ToList().AsReadOnly(); } } diff --git a/OfficeIMO.Pdf/Model/CheckBoxBlock.cs b/OfficeIMO.Pdf/Model/CheckBoxBlock.cs index cdaa4bd7d..fd802a635 100644 --- a/OfficeIMO.Pdf/Model/CheckBoxBlock.cs +++ b/OfficeIMO.Pdf/Model/CheckBoxBlock.cs @@ -8,8 +8,9 @@ internal sealed class CheckBoxBlock : IPdfBlock { public double SpacingBefore { get; } public double SpacingAfter { get; } public string CheckedValueName { get; } + public PdfFormFieldStyle Style { get; } - public CheckBoxBlock(string name, bool isChecked, double size, PdfAlign align, double spacingBefore, double spacingAfter, string checkedValueName) { + public CheckBoxBlock(string name, bool isChecked, double size, PdfAlign align, double spacingBefore, double spacingAfter, string checkedValueName, PdfFormFieldStyle? style = null) { Guard.NotNullOrWhiteSpace(name, nameof(name)); Guard.Positive(size, nameof(size)); Guard.LeftCenterRightAlign(align, nameof(align), "Check box"); @@ -27,5 +28,6 @@ public CheckBoxBlock(string name, bool isChecked, double size, PdfAlign align, d SpacingBefore = spacingBefore; SpacingAfter = spacingAfter; CheckedValueName = checkedValueName; + Style = style?.Clone() ?? new PdfFormFieldStyle(); } } diff --git a/OfficeIMO.Pdf/Model/ChoiceFieldBlock.cs b/OfficeIMO.Pdf/Model/ChoiceFieldBlock.cs index 8218087d4..a2e7c1343 100644 --- a/OfficeIMO.Pdf/Model/ChoiceFieldBlock.cs +++ b/OfficeIMO.Pdf/Model/ChoiceFieldBlock.cs @@ -13,12 +13,13 @@ internal sealed class ChoiceFieldBlock : IPdfBlock { public double SpacingAfter { get; } public bool IsComboBox { get; } public bool AllowsMultipleSelection { get; } + public PdfFormFieldStyle Style { get; } - public ChoiceFieldBlock(string name, IEnumerable options, string? value, double width, double height, PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, bool isComboBox) - : this(name, options, value is null ? null : new[] { value }, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox, allowsMultipleSelection: false) { + public ChoiceFieldBlock(string name, IEnumerable options, string? value, double width, double height, PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, bool isComboBox, PdfFormFieldStyle? style = null) + : this(name, options, value is null ? null : new[] { value }, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox, allowsMultipleSelection: false, style) { } - public ChoiceFieldBlock(string name, IEnumerable options, IEnumerable? values, double width, double height, PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, bool isComboBox, bool allowsMultipleSelection) { + public ChoiceFieldBlock(string name, IEnumerable options, IEnumerable? values, double width, double height, PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, bool isComboBox, bool allowsMultipleSelection, PdfFormFieldStyle? style = null) { Guard.NotNullOrWhiteSpace(name, nameof(name)); Guard.NotNull(options, nameof(options)); Guard.Positive(width, nameof(width)); @@ -63,6 +64,7 @@ public ChoiceFieldBlock(string name, IEnumerable options, IEnumerable NormalizeSelectedValues(IEnumerable? values, List options, HashSet optionSet, bool allowsMultipleSelection) { diff --git a/OfficeIMO.Pdf/Model/HeadingBlock.cs b/OfficeIMO.Pdf/Model/HeadingBlock.cs index b33382c88..70bc5c8c1 100644 --- a/OfficeIMO.Pdf/Model/HeadingBlock.cs +++ b/OfficeIMO.Pdf/Model/HeadingBlock.cs @@ -6,22 +6,33 @@ internal sealed class HeadingBlock : IPdfBlock { public PdfAlign Align { get; } public PdfColor? Color { get; } public string? LinkUri { get; } + public string? LinkDestinationName { get; } public string? LinkContents { get; } public PdfHeadingStyle? Style { get; } - public HeadingBlock(int level, string text, PdfAlign align, PdfColor? color, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null) { + public HeadingBlock(int level, string text, PdfAlign align, PdfColor? color, string? linkUri = null, PdfHeadingStyle? style = null, string? linkContents = null, string? linkDestinationName = null) { Guard.NotNullOrWhiteSpace(text, nameof(text)); Guard.LeftCenterRightAlign(align, nameof(align), "Heading"); - if (linkContents != null && linkUri == null) { - throw new System.ArgumentException("Heading link annotation contents require a heading link URI.", nameof(linkContents)); + if (linkUri != null && linkDestinationName != null) { + throw new System.ArgumentException("A heading link can target either a URI or a bookmark, not both.", nameof(linkDestinationName)); + } + + bool hasLinkTarget = linkUri != null || linkDestinationName != null; + if (linkContents != null && !hasLinkTarget) { + throw new System.ArgumentException("Heading link annotation contents require a link target.", nameof(linkContents)); } if (linkUri != null) { Guard.AbsoluteUri(linkUri, nameof(linkUri)); - if (linkContents != null) { - Guard.NotNullOrWhiteSpace(linkContents, nameof(linkContents)); - } } - Level = level; Text = text; Align = align; Color = color; LinkUri = linkUri; LinkContents = linkUri == null ? null : linkContents ?? text; Style = style?.Clone(); + if (linkDestinationName != null) { + Guard.NotNullOrWhiteSpace(linkDestinationName, nameof(linkDestinationName)); + } + + if (hasLinkTarget && linkContents != null) { + Guard.NotNullOrWhiteSpace(linkContents, nameof(linkContents)); + } + + Level = level; Text = text; Align = align; Color = color; LinkUri = linkUri; LinkDestinationName = linkDestinationName; LinkContents = hasLinkTarget ? linkContents ?? text : null; Style = style?.Clone(); } } diff --git a/OfficeIMO.Pdf/Model/NumberedListBlock.cs b/OfficeIMO.Pdf/Model/NumberedListBlock.cs index 1b231810c..c092ab927 100644 --- a/OfficeIMO.Pdf/Model/NumberedListBlock.cs +++ b/OfficeIMO.Pdf/Model/NumberedListBlock.cs @@ -2,6 +2,7 @@ namespace OfficeIMO.Pdf; internal sealed class NumberedListBlock : IPdfBlock { public System.Collections.Generic.IReadOnlyList Items { get; } + public System.Collections.Generic.IReadOnlyList RichItems { get; } public PdfAlign Align { get; } public PdfColor? Color { get; } public int StartNumber { get; } @@ -18,8 +19,36 @@ public NumberedListBlock(System.Collections.Generic.IEnumerable items, P Color = color; StartNumber = startNumber; Style = style?.Clone(); - var snapshot = new System.Collections.Generic.List(); - snapshot.AddRange(items.Where(item => item is not null)); - Items = snapshot.AsReadOnly(); + var richSnapshot = new System.Collections.Generic.List(); + foreach (string? item in items) { + if (item != null) { + richSnapshot.Add(new PdfListItem(item)); + } + } + + RichItems = richSnapshot.AsReadOnly(); + Items = richSnapshot.Select(item => item.Text).ToList().AsReadOnly(); + } + + public NumberedListBlock(System.Collections.Generic.IEnumerable items, PdfAlign align, PdfColor? color, int startNumber, PdfListStyle? style = null) { + Guard.NotNull(items, nameof(items)); + if (startNumber < 1) { + throw new System.ArgumentOutOfRangeException(nameof(startNumber), "Numbered lists must start at 1 or greater."); + } + + Guard.LeftCenterRightAlign(align, nameof(align), "Numbered list"); + Align = align; + Color = color; + StartNumber = startNumber; + Style = style?.Clone(); + var richSnapshot = new System.Collections.Generic.List(); + foreach (PdfListItem? item in items) { + if (item != null) { + richSnapshot.Add(new PdfListItem(item.Runs, item.BookmarkName, item.Marker)); + } + } + + RichItems = richSnapshot.AsReadOnly(); + Items = richSnapshot.Select(item => item.Text).ToList().AsReadOnly(); } } diff --git a/OfficeIMO.Pdf/Model/PanelStyle.cs b/OfficeIMO.Pdf/Model/PanelStyle.cs index 969271bf3..25d0e1b95 100644 --- a/OfficeIMO.Pdf/Model/PanelStyle.cs +++ b/OfficeIMO.Pdf/Model/PanelStyle.cs @@ -11,6 +11,10 @@ public class PanelStyle { private double? _maxWidth; private double _spacingBefore; private double _spacingAfter = 6; + private PdfPanelBorder? _topBorder; + private PdfPanelBorder? _rightBorder; + private PdfPanelBorder? _bottomBorder; + private PdfPanelBorder? _leftBorder; /// Background fill color. Set to null for no fill. public PdfColor? Background { get; set; } @@ -24,6 +28,31 @@ public double BorderWidth { _borderWidth = value; } } + /// Optional top border override. When set, it overrides the uniform border for this side. + public PdfPanelBorder? TopBorder { + get => _topBorder?.Clone(); + set => _topBorder = value?.Clone(); + } + /// Optional right border override. When set, it overrides the uniform border for this side. + public PdfPanelBorder? RightBorder { + get => _rightBorder?.Clone(); + set => _rightBorder = value?.Clone(); + } + /// Optional bottom border override. When set, it overrides the uniform border for this side. + public PdfPanelBorder? BottomBorder { + get => _bottomBorder?.Clone(); + set => _bottomBorder = value?.Clone(); + } + /// Optional left border override. When set, it overrides the uniform border for this side. + public PdfPanelBorder? LeftBorder { + get => _leftBorder?.Clone(); + set => _leftBorder = value?.Clone(); + } + internal PdfPanelBorder? TopBorderSnapshot => _topBorder; + internal PdfPanelBorder? RightBorderSnapshot => _rightBorder; + internal PdfPanelBorder? BottomBorderSnapshot => _bottomBorder; + internal PdfPanelBorder? LeftBorderSnapshot => _leftBorder; + internal bool HasSideBorders => _topBorder != null || _rightBorder != null || _bottomBorder != null || _leftBorder != null; /// Vertical padding inside the panel (points). public double PaddingY { get => _paddingY; @@ -83,6 +112,10 @@ public PanelStyle Clone() { Background = Background, BorderColor = BorderColor, BorderWidth = BorderWidth, + TopBorder = _topBorder, + RightBorder = _rightBorder, + BottomBorder = _bottomBorder, + LeftBorder = _leftBorder, PaddingY = PaddingY, PaddingX = PaddingX, MaxWidth = MaxWidth, diff --git a/OfficeIMO.Pdf/Model/PdfCellBorder.cs b/OfficeIMO.Pdf/Model/PdfCellBorder.cs index cce0cef49..718310e32 100644 --- a/OfficeIMO.Pdf/Model/PdfCellBorder.cs +++ b/OfficeIMO.Pdf/Model/PdfCellBorder.cs @@ -5,6 +5,12 @@ namespace OfficeIMO.Pdf; /// public sealed class PdfCellBorder { private double _width = 0.5; + private PdfCellBorderSide? _topBorder; + private PdfCellBorderSide? _rightBorder; + private PdfCellBorderSide? _bottomBorder; + private PdfCellBorderSide? _leftBorder; + private PdfCellBorderSide? _diagonalUpBorder; + private PdfCellBorderSide? _diagonalDownBorder; /// Border color. Set to null or use a zero width to suppress the border. public PdfColor? Color { get; set; } = new PdfColor(0.8, 0.8, 0.8); @@ -21,6 +27,55 @@ public double Width { } } + /// Border stroke dash style used by sides without an explicit side override. + public OfficeIMO.Drawing.OfficeStrokeDashStyle DashStyle { get; set; } + + /// Border line style used by sides without an explicit side override. + public PdfCellBorderLineStyle LineStyle { get; set; } + + /// Optional top border override. When set, it overrides the shared color and width for this side. + public PdfCellBorderSide? TopBorder { + get => _topBorder?.Clone(); + set => _topBorder = value?.Clone(); + } + + /// Optional right border override. When set, it overrides the shared color and width for this side. + public PdfCellBorderSide? RightBorder { + get => _rightBorder?.Clone(); + set => _rightBorder = value?.Clone(); + } + + /// Optional bottom border override. When set, it overrides the shared color and width for this side. + public PdfCellBorderSide? BottomBorder { + get => _bottomBorder?.Clone(); + set => _bottomBorder = value?.Clone(); + } + + /// Optional left border override. When set, it overrides the shared color and width for this side. + public PdfCellBorderSide? LeftBorder { + get => _leftBorder?.Clone(); + set => _leftBorder = value?.Clone(); + } + + /// Optional diagonal-up border override. The diagonal-up line runs from the bottom-left corner to the top-right corner. + public PdfCellBorderSide? DiagonalUpBorder { + get => _diagonalUpBorder?.Clone(); + set => _diagonalUpBorder = value?.Clone(); + } + + /// Optional diagonal-down border override. The diagonal-down line runs from the top-left corner to the bottom-right corner. + public PdfCellBorderSide? DiagonalDownBorder { + get => _diagonalDownBorder?.Clone(); + set => _diagonalDownBorder = value?.Clone(); + } + + internal PdfCellBorderSide? TopBorderSnapshot => _topBorder; + internal PdfCellBorderSide? RightBorderSnapshot => _rightBorder; + internal PdfCellBorderSide? BottomBorderSnapshot => _bottomBorder; + internal PdfCellBorderSide? LeftBorderSnapshot => _leftBorder; + internal PdfCellBorderSide? DiagonalUpBorderSnapshot => _diagonalUpBorder; + internal PdfCellBorderSide? DiagonalDownBorderSnapshot => _diagonalDownBorder; + /// Whether to draw the top side of the cell border. public bool Top { get; set; } = true; @@ -33,13 +88,29 @@ public double Width { /// Whether to draw the left side of the cell border. public bool Left { get; set; } = true; + /// Whether to draw the diagonal-up line from bottom-left to top-right. + public bool DiagonalUp { get; set; } + + /// Whether to draw the diagonal-down line from top-left to bottom-right. + public bool DiagonalDown { get; set; } + /// Creates a deep copy of this border style. public PdfCellBorder Clone() => new PdfCellBorder { Color = Color, Width = Width, + TopBorder = _topBorder, + RightBorder = _rightBorder, + BottomBorder = _bottomBorder, + LeftBorder = _leftBorder, + DiagonalUpBorder = _diagonalUpBorder, + DiagonalDownBorder = _diagonalDownBorder, + DashStyle = DashStyle, + LineStyle = LineStyle, Top = Top, Right = Right, Bottom = Bottom, - Left = Left + Left = Left, + DiagonalUp = DiagonalUp, + DiagonalDown = DiagonalDown }; } diff --git a/OfficeIMO.Pdf/Model/PdfCellBorderLineStyle.cs b/OfficeIMO.Pdf/Model/PdfCellBorderLineStyle.cs new file mode 100644 index 000000000..c8dafcc9d --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfCellBorderLineStyle.cs @@ -0,0 +1,12 @@ +namespace OfficeIMO.Pdf; + +/// +/// Describes how a table cell border line is drawn. +/// +public enum PdfCellBorderLineStyle { + /// Draw a single line. + Standard, + + /// Draw a double line. + TwoLine +} diff --git a/OfficeIMO.Pdf/Model/PdfCellBorderSide.cs b/OfficeIMO.Pdf/Model/PdfCellBorderSide.cs new file mode 100644 index 000000000..32c1a9ee5 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfCellBorderSide.cs @@ -0,0 +1,37 @@ +namespace OfficeIMO.Pdf; + +/// +/// Describes one side of a table cell border. +/// +public sealed class PdfCellBorderSide { + private double _width = 0.5; + + /// Border color. Set to null for no border on this side. + public PdfColor? Color { get; set; } + + /// Border stroke width in points. + public double Width { + get => _width; + set { + if (value < 0 || double.IsNaN(value) || double.IsInfinity(value)) { + throw new System.ArgumentException("Table cell border widths must be non-negative finite values.", nameof(Width)); + } + + _width = value; + } + } + + /// Border stroke dash style. + public OfficeIMO.Drawing.OfficeStrokeDashStyle DashStyle { get; set; } + + /// Border line style. + public PdfCellBorderLineStyle LineStyle { get; set; } + + /// Creates a copy of this table cell border side. + public PdfCellBorderSide Clone() => new PdfCellBorderSide { + Color = Color, + Width = Width, + DashStyle = DashStyle, + LineStyle = LineStyle + }; +} diff --git a/OfficeIMO.Pdf/Model/PdfCellDataBar.cs b/OfficeIMO.Pdf/Model/PdfCellDataBar.cs new file mode 100644 index 000000000..9916e4a84 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfCellDataBar.cs @@ -0,0 +1,31 @@ +namespace OfficeIMO.Pdf; + +/// +/// Describes a proportional visual bar drawn inside a table cell, behind the cell text. +/// +public sealed class PdfCellDataBar { + private double _ratio; + + /// Fill color used for the data bar. + public PdfColor Color { get; set; } = PdfColor.LightGray; + + /// Filled width as a 0..1 fraction of the cell content width. + public double Ratio { + get => _ratio; + set { + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0 || value > 1) { + throw new ArgumentOutOfRangeException(nameof(Ratio), "PDF table data bar ratio must be a finite value between 0 and 1."); + } + + _ratio = value; + } + } + + /// Creates a deep copy of this data bar. + public PdfCellDataBar Clone() { + return new PdfCellDataBar { + Color = Color, + Ratio = Ratio + }; + } +} diff --git a/OfficeIMO.Pdf/Model/PdfCellIcon.cs b/OfficeIMO.Pdf/Model/PdfCellIcon.cs new file mode 100644 index 000000000..919b87a08 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfCellIcon.cs @@ -0,0 +1,43 @@ +namespace OfficeIMO.Pdf; + +/// +/// Describes a small vector icon drawn inside a table cell before the cell text. +/// +public sealed class PdfCellIcon { + private PdfCellIconKind _kind; + private double _size = 8D; + + /// Icon shape to draw. + public PdfCellIconKind Kind { + get => _kind; + set { + if (value < PdfCellIconKind.Circle || value > PdfCellIconKind.TriangleDown) { + throw new System.ArgumentOutOfRangeException(nameof(value), value, "PDF table cell icon kind is not supported."); + } + + _kind = value; + } + } + + /// Icon fill color. + public PdfColor Color { get; set; } = PdfColor.Black; + + /// Icon size in points. + public double Size { + get => _size; + set { + if (value <= 0D || double.IsNaN(value) || double.IsInfinity(value)) { + throw new System.ArgumentException("PDF table cell icon size must be a positive finite value.", nameof(Size)); + } + + _size = value; + } + } + + /// Creates a copy of this table cell icon. + public PdfCellIcon Clone() => new PdfCellIcon { + Kind = Kind, + Color = Color, + Size = Size + }; +} diff --git a/OfficeIMO.Pdf/Model/PdfCellIconKind.cs b/OfficeIMO.Pdf/Model/PdfCellIconKind.cs new file mode 100644 index 000000000..cdd8fc3a7 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfCellIconKind.cs @@ -0,0 +1,19 @@ +namespace OfficeIMO.Pdf; + +/// +/// Built-in vector icon shapes that can be drawn inside table cells. +/// +public enum PdfCellIconKind { + /// A filled circle. + Circle, + /// A filled diamond. + Diamond, + /// A filled square. + Square, + /// A filled upward triangle. + TriangleUp, + /// A filled right-pointing triangle. + TriangleRight, + /// A filled downward triangle. + TriangleDown +} diff --git a/OfficeIMO.Pdf/Model/PdfCellPadding.cs b/OfficeIMO.Pdf/Model/PdfCellPadding.cs new file mode 100644 index 000000000..319991dc9 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfCellPadding.cs @@ -0,0 +1,61 @@ +namespace OfficeIMO.Pdf; + +/// +/// Describes optional per-side padding overrides for one table cell. +/// +public sealed class PdfCellPadding { + private double? _left; + private double? _right; + private double? _top; + private double? _bottom; + + /// Optional left padding in points. When null the table style value is used. + public double? Left { + get => _left; + set { + ValidateOptionalPadding(value, nameof(Left)); + _left = value; + } + } + + /// Optional right padding in points. When null the table style value is used. + public double? Right { + get => _right; + set { + ValidateOptionalPadding(value, nameof(Right)); + _right = value; + } + } + + /// Optional top padding in points. When null the table style value is used. + public double? Top { + get => _top; + set { + ValidateOptionalPadding(value, nameof(Top)); + _top = value; + } + } + + /// Optional bottom padding in points. When null the table style value is used. + public double? Bottom { + get => _bottom; + set { + ValidateOptionalPadding(value, nameof(Bottom)); + _bottom = value; + } + } + + /// Creates a copy of this table cell padding override. + public PdfCellPadding Clone() => new PdfCellPadding { + Left = Left, + Right = Right, + Top = Top, + Bottom = Bottom + }; + + private static void ValidateOptionalPadding(double? value, string paramName) { + if (value.HasValue && (value.Value < 0 || double.IsNaN(value.Value) || double.IsInfinity(value.Value))) { + throw new System.ArgumentException("Table cell padding values must be non-negative finite values.", paramName); + } + } +} diff --git a/OfficeIMO.Pdf/Model/PdfFormFieldStyle.cs b/OfficeIMO.Pdf/Model/PdfFormFieldStyle.cs new file mode 100644 index 000000000..432f8cd02 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfFormFieldStyle.cs @@ -0,0 +1,43 @@ +namespace OfficeIMO.Pdf; + +/// +/// Visual style for generated simple AcroForm fields. +/// +public class PdfFormFieldStyle { + private double _borderWidth = 1D; + + /// Background fill color. Set to null for transparent field appearance streams. + public PdfColor? BackgroundColor { get; set; } = PdfColor.White; + + /// Border stroke color. Set to null for no border stroke. + public PdfColor? BorderColor { get; set; } = new PdfColor(0.75, 0.75, 0.75); + + /// Text color for generated text and choice field appearance streams. + public PdfColor TextColor { get; set; } = PdfColor.Black; + + /// Check mark or radio dot color for generated button field appearance streams. + public PdfColor MarkColor { get; set; } = PdfColor.Black; + + /// Border stroke width in points. Set to 0 to suppress border drawing. + public double BorderWidth { + get => _borderWidth; + set { + if (value < 0 || double.IsNaN(value) || double.IsInfinity(value)) { + throw new ArgumentOutOfRangeException(nameof(value), value, "PDF form field border width must be a non-negative finite number."); + } + + _borderWidth = value; + } + } + + /// Creates a copy of this form field style. + public PdfFormFieldStyle Clone() { + return new PdfFormFieldStyle { + BackgroundColor = BackgroundColor, + BorderColor = BorderColor, + BorderWidth = BorderWidth, + TextColor = TextColor, + MarkColor = MarkColor + }; + } +} diff --git a/OfficeIMO.Pdf/Model/PdfHeaderFooterImage.cs b/OfficeIMO.Pdf/Model/PdfHeaderFooterImage.cs new file mode 100644 index 000000000..abc75a9c6 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfHeaderFooterImage.cs @@ -0,0 +1,52 @@ +using OfficeIMO.Drawing; + +namespace OfficeIMO.Pdf; + +/// +/// Describes a simple image rendered in a page header or footer. +/// +public sealed class PdfHeaderFooterImage { + /// Creates a header/footer image. + public PdfHeaderFooterImage(byte[] data, double width, double height, PdfAlign align = PdfAlign.Left, OfficeImageFit fit = OfficeImageFit.Stretch) { + Guard.NotNullOrEmpty(data, nameof(data)); + Guard.Positive(width, nameof(width)); + Guard.Positive(height, nameof(height)); + Guard.LeftCenterRightAlign(align, nameof(align), "PDF header/footer image"); + PdfDoc.ValidateImageFit(fit, nameof(fit)); + + OfficeImageInfo imageInfo = PdfDoc.ValidateImageBytes(data); + PdfDoc.ValidateImageFitDimensions(imageInfo, fit, nameof(fit)); + + Data = (byte[])data.Clone(); + Width = width; + Height = height; + Align = align; + Fit = fit; + Info = imageInfo; + } + + /// Image payload. + public byte[] Data { get; } + + /// Requested image box width in PDF points. + public double Width { get; } + + /// Requested image box height in PDF points. + public double Height { get; } + + /// Horizontal placement inside the page content width. + public PdfAlign Align { get; } + + /// Image fit behavior inside the requested image box. + public OfficeImageFit Fit { get; } + + /// Validated image metadata. + public OfficeImageInfo Info { get; } + + internal PdfHeaderFooterImage Clone() => new PdfHeaderFooterImage(Data, Width, Height, Align, Fit); + + internal ImageBlock ToImageBlock() => new ImageBlock(Data, Width, Height, Info, new PdfImageStyle { + Align = Align, + Fit = Fit + }); +} diff --git a/OfficeIMO.Pdf/Model/PdfHeaderFooterShape.cs b/OfficeIMO.Pdf/Model/PdfHeaderFooterShape.cs new file mode 100644 index 000000000..6ac18316d --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfHeaderFooterShape.cs @@ -0,0 +1,34 @@ +using OfficeIMO.Drawing; + +namespace OfficeIMO.Pdf; + +/// +/// Describes a simple shape rendered in a page header or footer. +/// +public sealed class PdfHeaderFooterShape { + private readonly ShapeBlock _block; + + /// Creates a header/footer shape. + public PdfHeaderFooterShape(OfficeShape shape, PdfAlign align = PdfAlign.Left) { + Guard.NotNull(shape, nameof(shape)); + Guard.LeftCenterRightAlign(align, nameof(align), "PDF header/footer shape"); + + _block = PdfDoc.CreateShapeBlock(shape, align, 0D, 0D); + } + + /// Shape width in PDF points. + public double Width => _block.Shape.Width; + + /// Shape height in PDF points. + public double Height => _block.Shape.Height; + + /// Horizontal placement inside the page content width. + public PdfAlign Align => _block.Align; + + /// Returns a copy of the shape payload. + public OfficeShape Shape => _block.Shape.Clone(); + + internal PdfHeaderFooterShape Clone() => new PdfHeaderFooterShape(_block.Shape, Align); + + internal ShapeBlock ToShapeBlock() => new ShapeBlock(_block.Shape, _block.Style); +} diff --git a/OfficeIMO.Pdf/Model/PdfHeadingStyle.cs b/OfficeIMO.Pdf/Model/PdfHeadingStyle.cs index c540b4c33..dd6174baf 100644 --- a/OfficeIMO.Pdf/Model/PdfHeadingStyle.cs +++ b/OfficeIMO.Pdf/Model/PdfHeadingStyle.cs @@ -48,6 +48,12 @@ public double? SpacingAfter { /// Heading text color. A heading block color overrides this value. public PdfColor? Color { get; set; } + /// When true, headings use the bold variant of the document font. + public bool Bold { get; set; } = true; + + /// When true, is honored even when the heading starts a page or column. + public bool ApplySpacingBeforeAtTop { get; set; } + /// When true, the heading moves to the next page when it would otherwise be separated from the following paragraph. public bool KeepWithNext { get; set; } = true; @@ -59,6 +65,8 @@ public PdfHeadingStyle Clone() { SpacingBefore = SpacingBefore, SpacingAfter = SpacingAfter, Color = Color, + Bold = Bold, + ApplySpacingBeforeAtTop = ApplySpacingBeforeAtTop, KeepWithNext = KeepWithNext }; } diff --git a/OfficeIMO.Pdf/Model/PdfListItem.cs b/OfficeIMO.Pdf/Model/PdfListItem.cs new file mode 100644 index 000000000..453c490b9 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfListItem.cs @@ -0,0 +1,65 @@ +namespace OfficeIMO.Pdf; + +/// +/// List item text with optional rich inline runs. +/// +public sealed class PdfListItem { + /// Plain text value for readback, wrapping fallback, and simple item APIs. + public string Text { get; } + /// Rich inline runs rendered as the list item body. + public System.Collections.Generic.IReadOnlyList Runs { get; } + /// Optional named destination anchored to this list item. + public string? BookmarkName { get; } + /// Optional explicit marker rendered instead of the list block default marker. + public string? Marker { get; } + + /// Create a plain list item. + public PdfListItem(string text, string? bookmarkName = null, string? marker = null) { + Guard.NotNull(text, nameof(text)); + if (bookmarkName != null) { + Guard.NotNullOrWhiteSpace(bookmarkName, nameof(bookmarkName)); + } + if (marker != null) { + Guard.NotNullOrWhiteSpace(marker, nameof(marker)); + } + + Text = text; + Runs = new[] { TextRun.Normal(text) }; + BookmarkName = bookmarkName; + Marker = marker; + } + + /// Create a rich list item from inline text runs. + public PdfListItem(System.Collections.Generic.IEnumerable runs, string? bookmarkName = null, string? marker = null) { + Guard.NotNull(runs, nameof(runs)); + if (bookmarkName != null) { + Guard.NotNullOrWhiteSpace(bookmarkName, nameof(bookmarkName)); + } + if (marker != null) { + Guard.NotNullOrWhiteSpace(marker, nameof(marker)); + } + + var snapshot = new System.Collections.Generic.List(); + foreach (TextRun? run in runs) { + if (run == null) { + throw new System.ArgumentException("List item runs cannot contain null entries.", nameof(runs)); + } + + snapshot.Add(run); + } + if (snapshot.Count == 0) { + throw new System.ArgumentException("List item must contain at least one text run.", nameof(runs)); + } + + Runs = snapshot.AsReadOnly(); + Text = string.Concat(snapshot.Select(run => run.Text)); + BookmarkName = bookmarkName; + Marker = marker; + } + + /// Create a normal unstyled item. + public static PdfListItem Plain(string text, string? bookmarkName = null, string? marker = null) => new PdfListItem(text, bookmarkName, marker); + + /// Create a rich item from inline text runs. + public static PdfListItem Rich(System.Collections.Generic.IEnumerable runs, string? bookmarkName = null, string? marker = null) => new PdfListItem(runs, bookmarkName, marker); +} diff --git a/OfficeIMO.Pdf/Model/PdfPanelBorder.cs b/OfficeIMO.Pdf/Model/PdfPanelBorder.cs new file mode 100644 index 000000000..38f9942d9 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfPanelBorder.cs @@ -0,0 +1,29 @@ +namespace OfficeIMO.Pdf; + +/// +/// Describes one side of a panel border. +/// +public sealed class PdfPanelBorder { + private double _width = 0.5; + + /// Border color. Set to null for no border on this side. + public PdfColor? Color { get; set; } + + /// Border stroke width in points. + public double Width { + get => _width; + set { + if (value < 0 || double.IsNaN(value) || double.IsInfinity(value)) { + throw new System.ArgumentException("Panel border width must be a non-negative finite value.", nameof(Width)); + } + + _width = value; + } + } + + /// Creates a copy of this panel border. + public PdfPanelBorder Clone() => new PdfPanelBorder { + Color = Color, + Width = Width + }; +} diff --git a/OfficeIMO.Pdf/Model/PdfParagraphBuilder.cs b/OfficeIMO.Pdf/Model/PdfParagraphBuilder.cs index 46350030d..932291e2b 100644 --- a/OfficeIMO.Pdf/Model/PdfParagraphBuilder.cs +++ b/OfficeIMO.Pdf/Model/PdfParagraphBuilder.cs @@ -11,6 +11,9 @@ public sealed class PdfParagraphBuilder { private bool _currentUnderline; private bool _currentStrike; private PdfTextBaseline _currentBaseline; + private double? _currentFontSize; + private PdfStandardFont? _currentFont; + private PdfColor? _currentBackgroundColor; /// Paragraph alignment. public PdfAlign Align { get; } @@ -27,6 +30,20 @@ public PdfParagraphBuilder(PdfAlign align, PdfColor? defaultColor) { /// Sets the current run color. public PdfParagraphBuilder Color(PdfColor color) { _currentColor = color; return this; } + /// Resets the current run color to the paragraph default color. + public PdfParagraphBuilder ResetColor() { _currentColor = DefaultColor; return this; } + /// Sets the current run font size in points. + public PdfParagraphBuilder FontSize(double fontSize) { Guard.Positive(fontSize, nameof(fontSize)); _currentFontSize = fontSize; return this; } + /// Resets the current run font size to the paragraph default font size. + public PdfParagraphBuilder ResetFontSize() { _currentFontSize = null; return this; } + /// Sets the current standard PDF font for subsequent runs. + public PdfParagraphBuilder Font(PdfStandardFont font) { Guard.StandardFont(font, nameof(font), "Text run font must be one of the supported standard PDF fonts."); _currentFont = font; return this; } + /// Resets the current run font to the paragraph/document font. + public PdfParagraphBuilder ResetFont() { _currentFont = null; return this; } + /// Sets the current run background color. + public PdfParagraphBuilder BackgroundColor(PdfColor color) { _currentBackgroundColor = color; return this; } + /// Resets the current run background color. + public PdfParagraphBuilder ResetBackgroundColor() { _currentBackgroundColor = null; return this; } /// Enables or disables bold for subsequent runs. public PdfParagraphBuilder Bold(bool enable = true) { _currentBold = enable; return this; } /// Enables or disables italic for subsequent runs. @@ -43,37 +60,37 @@ public PdfParagraphBuilder(PdfAlign align, PdfColor? defaultColor) { public PdfParagraphBuilder Baseline(PdfTextBaseline baseline) { Guard.TextBaseline(baseline, nameof(baseline)); _currentBaseline = baseline; return this; } /// Adds a text run using the current style flags. - public PdfParagraphBuilder Text(string text) { _runs.Add(new TextRun(text, _currentBold, _currentUnderline, _currentColor, _currentItalic, _currentStrike, baseline: _currentBaseline)); return this; } + public PdfParagraphBuilder Text(string text) { _runs.Add(new TextRun(text, _currentBold, _currentUnderline, _currentColor, _currentItalic, _currentStrike, fontSize: _currentFontSize, font: _currentFont, baseline: _currentBaseline, backgroundColor: _currentBackgroundColor)); return this; } /// Adds an explicit line break inside the current paragraph. public PdfParagraphBuilder LineBreak() { _runs.Add(TextRun.LineBreak()); return this; } /// Adds an explicit paragraph tab using the current style flags. - public PdfParagraphBuilder Tab(PdfTabLeaderStyle leader = PdfTabLeaderStyle.None, PdfTabAlignment alignment = PdfTabAlignment.Left) { _runs.Add(new TextRun("\t", _currentBold, _currentUnderline, _currentColor, _currentItalic, _currentStrike, baseline: _currentBaseline, tabLeader: leader, tabAlignment: alignment)); return this; } + public PdfParagraphBuilder Tab(PdfTabLeaderStyle leader = PdfTabLeaderStyle.None, PdfTabAlignment alignment = PdfTabAlignment.Left) { _runs.Add(new TextRun("\t", _currentBold, _currentUnderline, _currentColor, _currentItalic, _currentStrike, fontSize: _currentFontSize, font: _currentFont, baseline: _currentBaseline, tabLeader: leader, tabAlignment: alignment, backgroundColor: _currentBackgroundColor)); return this; } /// Adds a bold text run. - public PdfParagraphBuilder Bold(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: true, underline: false, color: color ?? _currentColor, italic: false)); return this; } + public PdfParagraphBuilder Bold(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: true, underline: false, color: color ?? _currentColor, italic: false, fontSize: _currentFontSize, font: _currentFont, backgroundColor: _currentBackgroundColor)); return this; } /// Adds an italic text run. - public PdfParagraphBuilder Italic(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: false, color: color ?? _currentColor, italic: true)); return this; } + public PdfParagraphBuilder Italic(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: false, color: color ?? _currentColor, italic: true, fontSize: _currentFontSize, font: _currentFont, backgroundColor: _currentBackgroundColor)); return this; } /// Adds an underlined text run. - public PdfParagraphBuilder Underlined(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: true, color: color ?? _currentColor, italic: false)); return this; } + public PdfParagraphBuilder Underlined(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: true, color: color ?? _currentColor, italic: false, fontSize: _currentFontSize, font: _currentFont, backgroundColor: _currentBackgroundColor)); return this; } /// Adds a strikethrough text run. - public PdfParagraphBuilder Strikethrough(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: false, color: color ?? _currentColor, italic: false, strike: true)); return this; } + public PdfParagraphBuilder Strikethrough(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: false, color: color ?? _currentColor, italic: false, strike: true, fontSize: _currentFontSize, font: _currentFont, backgroundColor: _currentBackgroundColor)); return this; } /// Adds a superscript text run. - public PdfParagraphBuilder Superscript(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: false, color: color ?? _currentColor, italic: false, strike: false, baseline: PdfTextBaseline.Superscript)); return this; } + public PdfParagraphBuilder Superscript(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: false, color: color ?? _currentColor, italic: false, strike: false, fontSize: _currentFontSize, font: _currentFont, baseline: PdfTextBaseline.Superscript, backgroundColor: _currentBackgroundColor)); return this; } /// Adds a subscript text run. - public PdfParagraphBuilder Subscript(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: false, color: color ?? _currentColor, italic: false, strike: false, baseline: PdfTextBaseline.Subscript)); return this; } + public PdfParagraphBuilder Subscript(string text, PdfColor? color = null) { _runs.Add(new TextRun(text, bold: false, underline: false, color: color ?? _currentColor, italic: false, strike: false, fontSize: _currentFontSize, font: _currentFont, baseline: PdfTextBaseline.Subscript, backgroundColor: _currentBackgroundColor)); return this; } /// Adds a hyperlink text run. /// Link text. /// Absolute URI to open. /// Optional link color. /// Whether to underline the link text (default true). /// Optional link annotation contents; defaults to the link text when omitted. - public PdfParagraphBuilder Link(string text, string uri, PdfColor? color = null, bool underline = true, string? contents = null) { _runs.Add(TextRun.Link(text, uri, color ?? _currentColor, underline, contents, _currentBaseline)); return this; } + public PdfParagraphBuilder Link(string text, string uri, PdfColor? color = null, bool underline = true, string? contents = null) { _runs.Add(TextRun.Link(text, uri, color ?? _currentColor, underline, contents, _currentBaseline, _currentFontSize, _currentBackgroundColor, _currentFont)); return this; } /// Adds a hyperlink text run that points to a document bookmark. /// Link text. /// Named destination created with . /// Optional link color. /// Whether to underline the link text (default true). /// Optional link annotation contents; defaults to the link text when omitted. - public PdfParagraphBuilder LinkToBookmark(string text, string bookmarkName, PdfColor? color = null, bool underline = true, string? contents = null) { _runs.Add(TextRun.LinkToBookmark(text, bookmarkName, color ?? _currentColor, underline, contents, _currentBaseline)); return this; } + public PdfParagraphBuilder LinkToBookmark(string text, string bookmarkName, PdfColor? color = null, bool underline = true, string? contents = null) { _runs.Add(TextRun.LinkToBookmark(text, bookmarkName, color ?? _currentColor, underline, contents, _currentBaseline, _currentFontSize, _currentBackgroundColor, _currentFont)); return this; } internal RichParagraphBlock Build(PdfParagraphStyle? style = null) => new RichParagraphBlock(_runs, Align, DefaultColor, style); } diff --git a/OfficeIMO.Pdf/Model/PdfRowStyle.cs b/OfficeIMO.Pdf/Model/PdfRowStyle.cs index 8a71c9998..1f56ffbb8 100644 --- a/OfficeIMO.Pdf/Model/PdfRowStyle.cs +++ b/OfficeIMO.Pdf/Model/PdfRowStyle.cs @@ -10,6 +10,7 @@ public sealed class PdfRowStyle { private double? _gap; private double _spacingBefore; private double _spacingAfter; + private double _columnSeparatorWidth; /// Horizontal gutter between row columns, in points. When null the row uses its explicit gap or the built-in Word-like gutter. public double? Gap { @@ -38,6 +39,18 @@ public double SpacingAfter { } } + /// Optional vertical separator color drawn between row columns. + public PdfColor? ColumnSeparatorColor { get; set; } + + /// Vertical separator line width, in points. A non-positive width disables separator drawing. + public double ColumnSeparatorWidth { + get => _columnSeparatorWidth; + set { + ValidateNonNegativeFiniteValue(value, nameof(ColumnSeparatorWidth), "Row column separator width must be a non-negative finite value."); + _columnSeparatorWidth = value; + } + } + /// When true, the row moves to the next page instead of splitting across pages. public bool KeepTogether { get; set; } @@ -50,6 +63,8 @@ public PdfRowStyle Clone() { Gap = Gap, SpacingBefore = SpacingBefore, SpacingAfter = SpacingAfter, + ColumnSeparatorColor = ColumnSeparatorColor, + ColumnSeparatorWidth = ColumnSeparatorWidth, KeepTogether = KeepTogether, KeepWithNext = KeepWithNext }; diff --git a/OfficeIMO.Pdf/Model/PdfTableCell.cs b/OfficeIMO.Pdf/Model/PdfTableCell.cs index 77a391031..9ddd53664 100644 --- a/OfficeIMO.Pdf/Model/PdfTableCell.cs +++ b/OfficeIMO.Pdf/Model/PdfTableCell.cs @@ -4,27 +4,56 @@ namespace OfficeIMO.Pdf; /// Represents a table cell with optional Word-like column and row spanning. /// public sealed class PdfTableCell { - /// Creates a table cell with text content, optional column/row spans, and optional URI link metadata. - public PdfTableCell(string? text, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1) { - if (columnSpan < 1) { - throw new System.ArgumentOutOfRangeException(nameof(columnSpan), "Table cell column span must be at least 1."); - } + /// Creates a table cell with text content, optional column/row spans, optional link metadata, images, and form fields. + public PdfTableCell(string? text, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1, System.Collections.Generic.IEnumerable? checkBoxes = null, System.Collections.Generic.IEnumerable? formFields = null, System.Collections.Generic.IEnumerable? images = null, string? linkDestinationName = null, string? namedDestinationName = null) { + Validate(columnSpan, rowSpan, linkUri, linkDestinationName, linkContents, namedDestinationName); + Text = text ?? string.Empty; + Runs = System.Array.AsReadOnly(new[] { TextRun.Normal(Text) }); + ColumnSpan = columnSpan; + RowSpan = rowSpan; + LinkUri = linkUri; + LinkDestinationName = linkDestinationName; + NamedDestinationName = namedDestinationName; + LinkContents = HasLinkTarget(linkUri, linkDestinationName) ? linkContents ?? Text : null; + CheckBoxes = SnapshotCheckBoxes(checkBoxes, nameof(checkBoxes)); + FormFields = SnapshotFormFields(formFields, nameof(formFields)); + Images = SnapshotImages(images, nameof(images)); + } - if (rowSpan < 1) { - throw new System.ArgumentOutOfRangeException(nameof(rowSpan), "Table cell row span must be at least 1."); + /// Creates a table cell with rich text runs, optional column/row spans, optional link metadata, images, and form fields. + public PdfTableCell(System.Collections.Generic.IEnumerable runs, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1, System.Collections.Generic.IEnumerable? checkBoxes = null, System.Collections.Generic.IEnumerable? formFields = null, System.Collections.Generic.IEnumerable? images = null, string? linkDestinationName = null, string? namedDestinationName = null) { + Guard.NotNull(runs, nameof(runs)); + Validate(columnSpan, rowSpan, linkUri, linkDestinationName, linkContents, namedDestinationName); + var snapshot = new System.Collections.Generic.List(); + var text = new System.Text.StringBuilder(); + foreach (TextRun run in runs) { + if (run is null) { + throw new System.ArgumentException("Table cell text runs cannot contain null entries.", nameof(runs)); + } + + snapshot.Add(run); + text.Append(run.Text); } - Guard.OptionalAbsoluteUri(linkUri, nameof(linkUri)); - Text = text ?? string.Empty; + Text = text.ToString(); + Runs = snapshot.AsReadOnly(); ColumnSpan = columnSpan; RowSpan = rowSpan; LinkUri = linkUri; - LinkContents = linkContents; + LinkDestinationName = linkDestinationName; + NamedDestinationName = namedDestinationName; + LinkContents = HasLinkTarget(linkUri, linkDestinationName) ? linkContents ?? Text : null; + CheckBoxes = SnapshotCheckBoxes(checkBoxes, nameof(checkBoxes)); + FormFields = SnapshotFormFields(formFields, nameof(formFields)); + Images = SnapshotImages(images, nameof(images)); } /// Cell text content. public string Text { get; } + /// Rich text runs for the cell. Plain text cells expose a single unstyled run. + public System.Collections.Generic.IReadOnlyList Runs { get; } + /// Number of logical columns covered by this cell. public int ColumnSpan { get; } @@ -34,17 +63,147 @@ public PdfTableCell(string? text, int columnSpan = 1, string? linkUri = null, st /// Optional absolute URI linked from this cell. public string? LinkUri { get; } + /// Optional PDF named destination linked from this cell. + public string? LinkDestinationName { get; } + + /// Optional PDF named destination defined at this cell. + public string? NamedDestinationName { get; } + /// Optional PDF annotation contents metadata for the cell link. public string? LinkContents { get; } + /// Simple AcroForm check boxes rendered inside this cell. + public System.Collections.Generic.IReadOnlyList CheckBoxes { get; } + + /// Simple AcroForm text and choice fields rendered inside this cell. + public System.Collections.Generic.IReadOnlyList FormFields { get; } + + /// Images rendered inside this cell. + public System.Collections.Generic.IReadOnlyList Images { get; } + /// Creates a single-column text cell. - public static PdfTableCell TextCell(string? text, string? linkUri = null, string? linkContents = null) => new PdfTableCell(text, linkUri: linkUri, linkContents: linkContents); + public static PdfTableCell TextCell(string? text, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null, string? namedDestinationName = null) => new PdfTableCell(text, linkUri: linkUri, linkContents: linkContents, linkDestinationName: linkDestinationName, namedDestinationName: namedDestinationName); + + /// Creates a single-column rich text cell. + public static PdfTableCell RichTextCell(System.Collections.Generic.IEnumerable runs, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null, string? namedDestinationName = null) => new PdfTableCell(runs, linkUri: linkUri, linkContents: linkContents, linkDestinationName: linkDestinationName, namedDestinationName: namedDestinationName); /// Creates a cell spanning multiple logical columns. - public static PdfTableCell Span(string? text, int columnSpan, string? linkUri = null, string? linkContents = null) => new PdfTableCell(text, columnSpan, linkUri, linkContents); + public static PdfTableCell Span(string? text, int columnSpan, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null) => new PdfTableCell(text, columnSpan, linkUri, linkContents, linkDestinationName: linkDestinationName); + + /// Creates a rich text cell spanning multiple logical columns. + public static PdfTableCell Span(System.Collections.Generic.IEnumerable runs, int columnSpan, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null) => new PdfTableCell(runs, columnSpan, linkUri, linkContents, linkDestinationName: linkDestinationName); /// Creates a merged cell spanning logical columns and rows. - public static PdfTableCell Merge(string? text, int columnSpan = 1, int rowSpan = 1, string? linkUri = null, string? linkContents = null) => new PdfTableCell(text, columnSpan, linkUri, linkContents, rowSpan); + public static PdfTableCell Merge(string? text, int columnSpan = 1, int rowSpan = 1, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null) => new PdfTableCell(text, columnSpan, linkUri, linkContents, rowSpan, linkDestinationName: linkDestinationName); + + /// Creates a rich text merged cell spanning logical columns and rows. + public static PdfTableCell Merge(System.Collections.Generic.IEnumerable runs, int columnSpan = 1, int rowSpan = 1, string? linkUri = null, string? linkContents = null, string? linkDestinationName = null) => new PdfTableCell(runs, columnSpan, linkUri, linkContents, rowSpan, linkDestinationName: linkDestinationName); + + /// Creates a table cell with rich text and simple AcroForm check boxes. + public static PdfTableCell WithCheckBoxes(System.Collections.Generic.IEnumerable runs, System.Collections.Generic.IEnumerable checkBoxes, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1, string? linkDestinationName = null) => new PdfTableCell(runs, columnSpan, linkUri, linkContents, rowSpan, checkBoxes, linkDestinationName: linkDestinationName); + + /// Creates a table cell with plain text and simple AcroForm check boxes. + public static PdfTableCell WithCheckBoxes(string? text, System.Collections.Generic.IEnumerable checkBoxes, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1, string? linkDestinationName = null) => new PdfTableCell(text, columnSpan, linkUri, linkContents, rowSpan, checkBoxes, linkDestinationName: linkDestinationName); + + /// Creates a table cell with rich text and simple AcroForm text or choice fields. + public static PdfTableCell WithFormFields(System.Collections.Generic.IEnumerable runs, System.Collections.Generic.IEnumerable formFields, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1, System.Collections.Generic.IEnumerable? checkBoxes = null, string? linkDestinationName = null) => new PdfTableCell(runs, columnSpan, linkUri, linkContents, rowSpan, checkBoxes, formFields, linkDestinationName: linkDestinationName); + + /// Creates a table cell with plain text and simple AcroForm text or choice fields. + public static PdfTableCell WithFormFields(string? text, System.Collections.Generic.IEnumerable formFields, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1, System.Collections.Generic.IEnumerable? checkBoxes = null, string? linkDestinationName = null) => new PdfTableCell(text, columnSpan, linkUri, linkContents, rowSpan, checkBoxes, formFields, linkDestinationName: linkDestinationName); + + /// Creates a table cell with rich text and images. + public static PdfTableCell WithImages(System.Collections.Generic.IEnumerable runs, System.Collections.Generic.IEnumerable images, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1, System.Collections.Generic.IEnumerable? checkBoxes = null, System.Collections.Generic.IEnumerable? formFields = null, string? linkDestinationName = null) => new PdfTableCell(runs, columnSpan, linkUri, linkContents, rowSpan, checkBoxes, formFields, images, linkDestinationName); + + /// Creates a table cell with plain text and images. + public static PdfTableCell WithImages(string? text, System.Collections.Generic.IEnumerable images, int columnSpan = 1, string? linkUri = null, string? linkContents = null, int rowSpan = 1, System.Collections.Generic.IEnumerable? checkBoxes = null, System.Collections.Generic.IEnumerable? formFields = null, string? linkDestinationName = null) => new PdfTableCell(text, columnSpan, linkUri, linkContents, rowSpan, checkBoxes, formFields, images, linkDestinationName); + + /// Returns a copy of this cell with a PDF named destination defined at the cell. + public PdfTableCell WithNamedDestination(string? namedDestinationName) => new PdfTableCell(Runs, ColumnSpan, LinkUri, LinkContents, RowSpan, CheckBoxes, FormFields, Images, LinkDestinationName, namedDestinationName); + + internal PdfTableCell Clone() => new PdfTableCell(Runs, ColumnSpan, LinkUri, LinkContents, RowSpan, CheckBoxes, FormFields, Images, LinkDestinationName, NamedDestinationName); + + private static void Validate(int columnSpan, int rowSpan, string? linkUri, string? linkDestinationName, string? linkContents, string? namedDestinationName) { + if (columnSpan < 1) { + throw new System.ArgumentOutOfRangeException(nameof(columnSpan), "Table cell column span must be at least 1."); + } + + if (rowSpan < 1) { + throw new System.ArgumentOutOfRangeException(nameof(rowSpan), "Table cell row span must be at least 1."); + } + + Guard.OptionalAbsoluteUri(linkUri, nameof(linkUri)); + + if (linkUri != null && linkDestinationName != null) { + throw new System.ArgumentException("A table cell link can target either a URI or a bookmark, not both.", nameof(linkDestinationName)); + } + + if (linkDestinationName != null) { + Guard.NotNullOrWhiteSpace(linkDestinationName, nameof(linkDestinationName)); + } + + if (namedDestinationName != null) { + Guard.NotNullOrWhiteSpace(namedDestinationName, nameof(namedDestinationName)); + } + + if (linkContents != null && !HasLinkTarget(linkUri, linkDestinationName)) { + throw new System.ArgumentException("Link annotation contents require a link target.", nameof(linkContents)); + } + + if (linkContents != null) { + Guard.NotNullOrWhiteSpace(linkContents, nameof(linkContents)); + } + } + + private static bool HasLinkTarget(string? linkUri, string? linkDestinationName) => linkUri != null || linkDestinationName != null; + + private static System.Collections.ObjectModel.ReadOnlyCollection SnapshotCheckBoxes(System.Collections.Generic.IEnumerable? checkBoxes, string paramName) { + if (checkBoxes == null) { + return System.Array.AsReadOnly(System.Array.Empty()); + } - internal PdfTableCell Clone() => new PdfTableCell(Text, ColumnSpan, LinkUri, LinkContents, RowSpan); + var snapshot = new System.Collections.Generic.List(); + foreach (PdfTableCellCheckBox checkBox in checkBoxes) { + if (checkBox == null) { + throw new System.ArgumentException("Table cell check boxes cannot contain null entries.", paramName); + } + + snapshot.Add(checkBox.Clone()); + } + + return snapshot.AsReadOnly(); + } + + private static System.Collections.ObjectModel.ReadOnlyCollection SnapshotFormFields(System.Collections.Generic.IEnumerable? formFields, string paramName) { + if (formFields == null) { + return System.Array.AsReadOnly(System.Array.Empty()); + } + + var snapshot = new System.Collections.Generic.List(); + foreach (PdfTableCellFormField formField in formFields) { + if (formField == null) { + throw new System.ArgumentException("Table cell form fields cannot contain null entries.", paramName); + } + + snapshot.Add(formField.Clone()); + } + + return snapshot.AsReadOnly(); + } + + private static System.Collections.ObjectModel.ReadOnlyCollection SnapshotImages(System.Collections.Generic.IEnumerable? images, string paramName) { + if (images == null) { + return System.Array.AsReadOnly(System.Array.Empty()); + } + + var snapshot = new System.Collections.Generic.List(); + foreach (PdfTableCellImage image in images) { + if (image == null) { + throw new System.ArgumentException("Table cell images cannot contain null entries.", paramName); + } + + snapshot.Add(image.Clone()); + } + + return snapshot.AsReadOnly(); + } } diff --git a/OfficeIMO.Pdf/Model/PdfTableCellCheckBox.cs b/OfficeIMO.Pdf/Model/PdfTableCellCheckBox.cs new file mode 100644 index 000000000..f20532fb3 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfTableCellCheckBox.cs @@ -0,0 +1,41 @@ +namespace OfficeIMO.Pdf; + +/// +/// Represents a simple AcroForm check box rendered inside a PDF table cell. +/// +public sealed class PdfTableCellCheckBox { + private readonly PdfFormFieldStyle _style; + + /// Creates a table-cell check box. + public PdfTableCellCheckBox(string name, bool isChecked = false, double size = 12, string checkedValueName = "Yes", PdfFormFieldStyle? style = null) { + Guard.NotNullOrWhiteSpace(name, nameof(name)); + Guard.Positive(size, nameof(size)); + Guard.NotNullOrWhiteSpace(checkedValueName, nameof(checkedValueName)); + if (string.Equals(checkedValueName, "Off", System.StringComparison.Ordinal)) { + throw new System.ArgumentException("Table cell check box selected value name cannot be Off.", nameof(checkedValueName)); + } + + Name = name; + IsChecked = isChecked; + Size = size; + CheckedValueName = checkedValueName; + _style = style?.Clone() ?? new PdfFormFieldStyle(); + } + + /// Field name written to the AcroForm tree. + public string Name { get; } + + /// Whether the generated check box is initially checked. + public bool IsChecked { get; } + + /// Visual square size in points. + public double Size { get; } + + /// PDF button appearance state name used when checked. + public string CheckedValueName { get; } + + /// Visual style for the generated check box appearance streams. + public PdfFormFieldStyle Style => _style.Clone(); + + internal PdfTableCellCheckBox Clone() => new PdfTableCellCheckBox(Name, IsChecked, Size, CheckedValueName, _style); +} diff --git a/OfficeIMO.Pdf/Model/PdfTableCellFormField.cs b/OfficeIMO.Pdf/Model/PdfTableCellFormField.cs new file mode 100644 index 000000000..3f0c9e592 --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfTableCellFormField.cs @@ -0,0 +1,120 @@ +namespace OfficeIMO.Pdf; + +/// +/// Represents a simple AcroForm text or choice field rendered inside a PDF table cell. +/// +public sealed class PdfTableCellFormField { + private readonly PdfFormFieldStyle _style; + + private PdfTableCellFormField( + PdfTableCellFormFieldKind kind, + string name, + double width, + double height, + double fontSize, + string? value, + System.Collections.Generic.IEnumerable? options, + bool isComboBox, + PdfFormFieldStyle? style) { + Guard.NotNullOrWhiteSpace(name, nameof(name)); + Guard.Positive(width, nameof(width)); + Guard.Positive(height, nameof(height)); + Guard.Positive(fontSize, nameof(fontSize)); + + Kind = kind; + Name = name; + Width = width; + Height = height; + FontSize = fontSize; + IsComboBox = isComboBox; + _style = style?.Clone() ?? new PdfFormFieldStyle(); + + if (kind == PdfTableCellFormFieldKind.Text) { + Value = value ?? string.Empty; + Options = System.Array.AsReadOnly(System.Array.Empty()); + Values = System.Array.AsReadOnly(new[] { Value }); + return; + } + + System.Collections.Generic.List normalizedOptions = NormalizeOptions(options); + string selectedValue = string.IsNullOrWhiteSpace(value) ? normalizedOptions[0] : value!; + if (!normalizedOptions.Contains(selectedValue, System.StringComparer.Ordinal)) { + throw new System.ArgumentException("PDF table cell choice field value must match the provided options.", nameof(value)); + } + + Value = selectedValue; + Values = System.Array.AsReadOnly(new[] { selectedValue }); + Options = normalizedOptions.AsReadOnly(); + } + + /// Kind of AcroForm field emitted for this table cell item. + public PdfTableCellFormFieldKind Kind { get; } + + /// Field name written to the AcroForm tree. + public string Name { get; } + + /// Initial scalar value. + public string Value { get; } + + /// Initial selected values. Scalar table-cell fields expose one value. + public System.Collections.Generic.IReadOnlyList Values { get; } + + /// Available choice options. Empty for text fields. + public System.Collections.Generic.IReadOnlyList Options { get; } + + /// Preferred visual width in points. Rendering clamps this to the available cell width. + public double Width { get; } + + /// Visual height in points. + public double Height { get; } + + /// Text font size in points. + public double FontSize { get; } + + /// Whether a choice field is emitted as a combo box. + public bool IsComboBox { get; } + + /// Visual style for the generated field appearance stream. + public PdfFormFieldStyle Style => _style.Clone(); + + /// Creates a table-cell text field. + public static PdfTableCellFormField TextField(string name, string? value = null, double width = 120, double height = 18, double fontSize = 10, PdfFormFieldStyle? style = null) => + new PdfTableCellFormField(PdfTableCellFormFieldKind.Text, name, width, height, fontSize, value, null, isComboBox: false, style); + + /// Creates a table-cell scalar choice field. + public static PdfTableCellFormField ChoiceField(string name, System.Collections.Generic.IEnumerable options, string? value = null, double width = 120, double height = 18, double fontSize = 10, bool isComboBox = true, PdfFormFieldStyle? style = null) => + new PdfTableCellFormField(PdfTableCellFormFieldKind.Choice, name, width, height, fontSize, value, options, isComboBox, style); + + internal PdfTableCellFormField Clone() => + new PdfTableCellFormField(Kind, Name, Width, Height, FontSize, Value, Options, IsComboBox, _style); + + private static System.Collections.Generic.List NormalizeOptions(System.Collections.Generic.IEnumerable? options) { + Guard.NotNull(options, nameof(options)); + + var normalized = new System.Collections.Generic.List(); + var seen = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); + foreach (string? option in options!) { + Guard.NotNullOrWhiteSpace(option, nameof(options)); + if (!seen.Add(option!)) { + throw new System.ArgumentException("PDF table cell choice field options must be unique.", nameof(options)); + } + + normalized.Add(option!); + } + + if (normalized.Count == 0) { + throw new System.ArgumentException("PDF table cell choice field requires at least one option.", nameof(options)); + } + + return normalized; + } +} + +/// Supported table-cell AcroForm field kinds. +public enum PdfTableCellFormFieldKind { + /// Simple text field. + Text, + + /// Scalar choice field. + Choice +} diff --git a/OfficeIMO.Pdf/Model/PdfTableCellImage.cs b/OfficeIMO.Pdf/Model/PdfTableCellImage.cs new file mode 100644 index 000000000..8bf7e73ee --- /dev/null +++ b/OfficeIMO.Pdf/Model/PdfTableCellImage.cs @@ -0,0 +1,71 @@ +using OfficeIMO.Drawing; + +namespace OfficeIMO.Pdf; + +/// +/// Represents an image rendered inside a table cell. +/// +public sealed class PdfTableCellImage { + /// Creates a supported table-cell image. JPEG and simple PNG images are supported. + public PdfTableCellImage(byte[] data, double width, double height, PdfImageStyle? style = null, string? linkUri = null, string? linkContents = null) { + Guard.NotNullOrEmpty(data, nameof(data)); + Guard.Positive(width, nameof(width)); + Guard.Positive(height, nameof(height)); + Guard.OptionalAbsoluteUri(linkUri, nameof(linkUri)); + if (linkContents != null && linkUri == null) { + throw new ArgumentException("Table cell image link contents require a link URI.", nameof(linkContents)); + } + + if (linkContents != null) { + Guard.NotNullOrWhiteSpace(linkContents, nameof(linkContents)); + } + + PdfImageStyle? imageStyle = style?.Clone(); + if (imageStyle != null) { + PdfDoc.ValidateImageStyleForBox(imageStyle, width, height, nameof(style)); + } + + OfficeImageInfo info = PdfDoc.ValidateImageBytes(data); + if (imageStyle != null) { + PdfDoc.ValidateImageFitDimensions(info, imageStyle.Fit, nameof(style)); + } + + Data = (byte[])data.Clone(); + Width = width; + Height = height; + Info = info; + Style = imageStyle; + LinkUri = linkUri; + LinkContents = linkUri == null ? null : linkContents ?? "Table cell image"; + } + + /// Image bytes. + public byte[] Data { get; } + + /// Target image width in PDF points. + public double Width { get; } + + /// Target image height in PDF points. + public double Height { get; } + + /// Detected source image metadata. + public OfficeImageInfo Info { get; } + + /// Optional image style. When omitted, the table cell alignment is used. + public PdfImageStyle? Style { get; } + + /// Optional absolute URI linked from the image rectangle. + public string? LinkUri { get; } + + /// Optional PDF annotation contents metadata for the image link. + public string? LinkContents { get; } + + internal PdfTableCellImage Clone() => new PdfTableCellImage(Data, Width, Height, Style, LinkUri, LinkContents); + + internal ImageBlock ToImageBlock(PdfAlign fallbackAlign) { + PdfImageStyle style = Style?.Clone() ?? new PdfImageStyle { + Align = fallbackAlign + }; + return new ImageBlock(Data, Width, Height, Info, style, LinkUri, LinkContents); + } +} diff --git a/OfficeIMO.Pdf/Model/PdfTableStyle.cs b/OfficeIMO.Pdf/Model/PdfTableStyle.cs index dfecb11eb..a8980c0cb 100644 --- a/OfficeIMO.Pdf/Model/PdfTableStyle.cs +++ b/OfficeIMO.Pdf/Model/PdfTableStyle.cs @@ -14,19 +14,28 @@ public class PdfTableStyle { private System.Collections.Generic.List? _columnWidthWeights; private System.Collections.Generic.List? _bodyColumnFills; private System.Collections.Generic.Dictionary<(int Row, int Column), PdfColor>? _cellFills; + private System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellDataBar>? _cellDataBars; + private System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellIcon>? _cellIcons; private System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellBorder>? _cellBorders; + private System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellPadding>? _cellPaddings; + private System.Collections.Generic.Dictionary<(int Row, int Column), PdfColumnAlign>? _cellAlignments; + private System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellVerticalAlign>? _cellVerticalAlignments; + private System.Collections.Generic.List? _rowMinHeights; + private System.Collections.Generic.List? _rowAllowBreakAcrossPages; private double _borderWidth = 0.5; private double _rowSeparatorWidth; private double _headerSeparatorWidth; private double _footerSeparatorWidth; private int _headerRowCount = 1; private int _footerRowCount; + private int? _repeatHeaderRowCount; private double _cellPaddingX = 4; private double _cellPaddingY = 2; private double? _cellPaddingLeft; private double? _cellPaddingRight; private double? _cellPaddingTop; private double? _cellPaddingBottom; + private double _cellSpacing; private double _minRowHeight; private double _spacingBefore; private double? _captionFontSize; @@ -106,6 +115,56 @@ public System.Collections.Generic.List? BodyColumnFills { _cellFills = value == null ? null : new System.Collections.Generic.Dictionary<(int Row, int Column), PdfColor>(value); } } + /// Optional proportional bars drawn inside cells, behind cell text, keyed by zero-based row and column. + public System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellDataBar>? CellDataBars { + get => _cellDataBars; + set { + if (value == null) { + _cellDataBars = null; + return; + } + + var dataBars = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellDataBar>(); + foreach (var cellDataBar in value) { + if (cellDataBar.Key.Row < 0 || cellDataBar.Key.Column < 0) { + throw new System.ArgumentException("Table cell data bar coordinates cannot be negative.", nameof(CellDataBars)); + } + + if (cellDataBar.Value == null) { + throw new System.ArgumentException("Table cell data bars cannot contain null values.", nameof(CellDataBars)); + } + + dataBars[cellDataBar.Key] = cellDataBar.Value.Clone(); + } + + _cellDataBars = dataBars; + } + } + /// Optional small vector icons drawn inside cells before cell text, keyed by zero-based row and column. + public System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellIcon>? CellIcons { + get => _cellIcons; + set { + if (value == null) { + _cellIcons = null; + return; + } + + var icons = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellIcon>(); + foreach (var cellIcon in value) { + if (cellIcon.Key.Row < 0 || cellIcon.Key.Column < 0) { + throw new System.ArgumentException("Table cell icon coordinates cannot be negative.", nameof(CellIcons)); + } + + if (cellIcon.Value == null) { + throw new System.ArgumentException("Table cell icons cannot contain null values.", nameof(CellIcons)); + } + + icons[cellIcon.Key] = cellIcon.Value.Clone(); + } + + _cellIcons = icons; + } + } /// Optional side-specific per-cell border overrides keyed by zero-based row and column. public System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellBorder>? CellBorders { get => _cellBorders; @@ -128,6 +187,72 @@ public System.Collections.Generic.List? BodyColumnFills { _cellBorders = borders; } } + /// Optional per-cell padding overrides keyed by zero-based row and column. + public System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellPadding>? CellPaddings { + get => _cellPaddings; + set { + if (value == null) { + _cellPaddings = null; + return; + } + + var paddings = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellPadding>(); + foreach (var cellPadding in value) { + if (cellPadding.Key.Row < 0 || cellPadding.Key.Column < 0) { + throw new System.ArgumentException("Table cell padding coordinates cannot be negative.", nameof(CellPaddings)); + } + + ValidateCellPadding(cellPadding.Value, nameof(CellPaddings)); + paddings[cellPadding.Key] = cellPadding.Value.Clone(); + } + + _cellPaddings = paddings; + } + } + /// Optional per-cell horizontal alignment overrides keyed by zero-based row and column. + public System.Collections.Generic.Dictionary<(int Row, int Column), PdfColumnAlign>? CellAlignments { + get => _cellAlignments; + set { + if (value == null) { + _cellAlignments = null; + return; + } + + var alignments = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfColumnAlign>(); + foreach (var cellAlignment in value) { + if (cellAlignment.Key.Row < 0 || cellAlignment.Key.Column < 0) { + throw new System.ArgumentException("Table cell alignment coordinates cannot be negative.", nameof(CellAlignments)); + } + + Guard.TableColumnAlign(cellAlignment.Value, nameof(CellAlignments)); + alignments[cellAlignment.Key] = cellAlignment.Value; + } + + _cellAlignments = alignments; + } + } + /// Optional per-cell vertical alignment overrides keyed by zero-based row and column. + public System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellVerticalAlign>? CellVerticalAlignments { + get => _cellVerticalAlignments; + set { + if (value == null) { + _cellVerticalAlignments = null; + return; + } + + var alignments = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellVerticalAlign>(); + foreach (var cellAlignment in value) { + if (cellAlignment.Key.Row < 0 || cellAlignment.Key.Column < 0) { + throw new System.ArgumentException("Table cell vertical alignment coordinates cannot be negative.", nameof(CellVerticalAlignments)); + } + + Guard.TableCellVerticalAlign(cellAlignment.Value, nameof(CellVerticalAlignments)); + alignments[cellAlignment.Key] = cellAlignment.Value; + } + + _cellVerticalAlignments = alignments; + } + } /// Text color for body rows. When null the writer’s default text color is used. public PdfColor? TextColor { get; set; } /// Font size for body cells, in points. When null the document default font size is used. @@ -158,7 +283,7 @@ public double? HeaderFontSize { } /// When true, header cells use the bold variant of the document font family. public bool HeaderBold { get; set; } = true; - /// Number of leading rows to render as table headers and repeat on following pages. Defaults to 1. + /// Number of leading rows to render as table headers. Defaults to 1. public int HeaderRowCount { get => _headerRowCount; set { @@ -166,6 +291,16 @@ public int HeaderRowCount { _headerRowCount = value; } } + /// Number of leading header rows to repeat on following pages. When null, all configured header rows repeat. + public int? RepeatHeaderRowCount { + get => _repeatHeaderRowCount; + set { + if (value.HasValue) { + ValidateNonNegativeValue(value.Value, nameof(RepeatHeaderRowCount), "Table repeating header row count cannot be negative."); + } + _repeatHeaderRowCount = value; + } + } /// Number of trailing rows to render as table footers. Defaults to 0. public int FooterRowCount { get => _footerRowCount; @@ -234,6 +369,14 @@ public double? CellPaddingBottom { _cellPaddingBottom = value; } } + /// Optional spacing between adjacent table cells, in points. + public double CellSpacing { + get => _cellSpacing; + set { + ValidateNonNegativeFiniteValue(value, nameof(CellSpacing), "Table cell spacing must be a non-negative finite value."); + _cellSpacing = value; + } + } /// Optional minimum row height in points. Set to 0 to size rows from wrapped content. public double MinRowHeight { get => _minRowHeight; @@ -242,6 +385,24 @@ public double MinRowHeight { _minRowHeight = value; } } + /// Optional per-row minimum heights in points. Null entries fall back to . + public System.Collections.Generic.List? RowMinHeights { + get => _rowMinHeights; + set { + if (value == null) { + _rowMinHeights = null; + return; + } + + var heights = new System.Collections.Generic.List(value.Count); + foreach (double? height in value) { + ValidateOptionalNonNegativeFiniteValue(height, nameof(RowMinHeights), "Table row minimum heights must be non-negative finite values."); + heights.Add(height); + } + + _rowMinHeights = heights; + } + } /// Vertical space before the table, in points. public double SpacingBefore { get => _spacingBefore; @@ -378,6 +539,11 @@ public System.Collections.Generic.List? ColumnWidthWeights { public bool KeepWithNext { get; set; } /// When true, a single row that is taller than the page frame may split across pages by wrapped text line. public bool AllowRowBreakAcrossPages { get; set; } = true; + /// Optional per-row row-break overrides. Null entries fall back to . + public System.Collections.Generic.List? RowAllowBreakAcrossPages { + get => _rowAllowBreakAcrossPages; + set => _rowAllowBreakAcrossPages = value == null ? null : new System.Collections.Generic.List(value); + } /// Creates a deep copy of this style. public PdfTableStyle Clone() { @@ -400,6 +566,7 @@ public PdfTableStyle Clone() { HeaderFontSize = HeaderFontSize, HeaderBold = HeaderBold, HeaderRowCount = HeaderRowCount, + RepeatHeaderRowCount = RepeatHeaderRowCount, FooterRowCount = FooterRowCount, FooterTextColor = FooterTextColor, FooterFontSize = FooterFontSize, @@ -410,7 +577,9 @@ public PdfTableStyle Clone() { CellPaddingRight = CellPaddingRight, CellPaddingTop = CellPaddingTop, CellPaddingBottom = CellPaddingBottom, + CellSpacing = CellSpacing, MinRowHeight = MinRowHeight, + RowMinHeights = RowMinHeights, SpacingBefore = SpacingBefore, Caption = Caption, CaptionAlign = CaptionAlign, @@ -425,16 +594,37 @@ public PdfTableStyle Clone() { RightAlignNumeric = RightAlignNumeric, KeepTogether = KeepTogether, KeepWithNext = KeepWithNext, - AllowRowBreakAcrossPages = AllowRowBreakAcrossPages + AllowRowBreakAcrossPages = AllowRowBreakAcrossPages, + RowAllowBreakAcrossPages = RowAllowBreakAcrossPages }; if (BodyColumnFills != null) clone.BodyColumnFills = new System.Collections.Generic.List(BodyColumnFills); if (CellFills != null) clone.CellFills = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfColor>(CellFills); + if (CellDataBars != null) { + clone.CellDataBars = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellDataBar>(); + foreach (var cellDataBar in CellDataBars) { + clone.CellDataBars[cellDataBar.Key] = cellDataBar.Value.Clone(); + } + } + if (CellIcons != null) { + clone.CellIcons = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellIcon>(); + foreach (var cellIcon in CellIcons) { + clone.CellIcons[cellIcon.Key] = cellIcon.Value.Clone(); + } + } if (CellBorders != null) { clone.CellBorders = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellBorder>(); foreach (var cellBorder in CellBorders) { clone.CellBorders[cellBorder.Key] = cellBorder.Value.Clone(); } } + if (CellPaddings != null) { + clone.CellPaddings = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellPadding>(); + foreach (var cellPadding in CellPaddings) { + clone.CellPaddings[cellPadding.Key] = cellPadding.Value.Clone(); + } + } + if (CellAlignments != null) clone.CellAlignments = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfColumnAlign>(CellAlignments); + if (CellVerticalAlignments != null) clone.CellVerticalAlignments = new System.Collections.Generic.Dictionary<(int Row, int Column), PdfCellVerticalAlign>(CellVerticalAlignments); if (Alignments != null) clone.Alignments = new System.Collections.Generic.List(Alignments); if (VerticalAlignments != null) clone.VerticalAlignments = new System.Collections.Generic.List(VerticalAlignments); if (ColumnWidthPoints != null) clone.ColumnWidthPoints = new System.Collections.Generic.List(ColumnWidthPoints); @@ -509,5 +699,11 @@ private static void ValidateCellBorder(PdfCellBorder? border, string paramName) throw new System.ArgumentException("Table cell border widths must be non-negative finite values.", paramName); } } + + private static void ValidateCellPadding(PdfCellPadding? padding, string paramName) { + if (padding == null) { + throw new System.ArgumentException("Table cell padding values cannot be null.", paramName); + } + } } diff --git a/OfficeIMO.Pdf/Model/RadioButtonGroupBlock.cs b/OfficeIMO.Pdf/Model/RadioButtonGroupBlock.cs new file mode 100644 index 000000000..540de364c --- /dev/null +++ b/OfficeIMO.Pdf/Model/RadioButtonGroupBlock.cs @@ -0,0 +1,68 @@ +namespace OfficeIMO.Pdf; + +internal sealed class RadioButtonGroupBlock : IPdfBlock { + public string Name { get; } + public IReadOnlyList Options { get; } + public string Value { get; } + public double Size { get; } + public double Gap { get; } + public PdfAlign Align { get; } + public double SpacingBefore { get; } + public double SpacingAfter { get; } + public PdfFormFieldStyle Style { get; } + public double Height => Options.Count * Size + Math.Max(0, Options.Count - 1) * Gap; + + public RadioButtonGroupBlock(string name, IEnumerable options, string? value, double size, double gap, PdfAlign align, double spacingBefore, double spacingAfter, PdfFormFieldStyle? style = null) { + Guard.NotNullOrWhiteSpace(name, nameof(name)); + Guard.NotNull(options, nameof(options)); + Guard.Positive(size, nameof(size)); + Guard.NonNegative(gap, nameof(gap)); + Guard.LeftCenterRightAlign(align, nameof(align), "Radio button group"); + Guard.NonNegative(spacingBefore, nameof(spacingBefore)); + Guard.NonNegative(spacingAfter, nameof(spacingAfter)); + + var normalizedOptions = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (string? option in options) { + Guard.NotNullOrWhiteSpace(option, nameof(options)); + if (string.Equals(option, "Off", StringComparison.Ordinal)) { + throw new ArgumentException("PDF radio button option value cannot be Off.", nameof(options)); + } + + ValidateAsciiPdfNameValue(option!, nameof(options)); + if (!seen.Add(option!)) { + throw new ArgumentException("PDF radio button options must be unique.", nameof(options)); + } + + normalizedOptions.Add(option!); + } + + if (normalizedOptions.Count == 0) { + throw new ArgumentException("PDF radio button group requires at least one option.", nameof(options)); + } + + string selectedValue = value ?? normalizedOptions[0]; + Guard.NotNullOrWhiteSpace(selectedValue, nameof(value)); + if (!seen.Contains(selectedValue)) { + throw new ArgumentException("PDF radio button value must match the provided options.", nameof(value)); + } + + Name = name; + Options = normalizedOptions.AsReadOnly(); + Value = selectedValue; + Size = size; + Gap = gap; + Align = align; + SpacingBefore = spacingBefore; + SpacingAfter = spacingAfter; + Style = style?.Clone() ?? new PdfFormFieldStyle(); + } + + private static void ValidateAsciiPdfNameValue(string value, string paramName) { + for (int i = 0; i < value.Length; i++) { + if (value[i] > 0x7E) { + throw new ArgumentException("PDF radio button option values must contain only ASCII PDF name characters.", paramName); + } + } + } +} diff --git a/OfficeIMO.Pdf/Model/TableBlock.cs b/OfficeIMO.Pdf/Model/TableBlock.cs index d9178e88b..9f300bd1c 100644 --- a/OfficeIMO.Pdf/Model/TableBlock.cs +++ b/OfficeIMO.Pdf/Model/TableBlock.cs @@ -30,6 +30,7 @@ public TableBlock(System.Collections.Generic.IEnumerable rows, PdfAlig Cells = cellSnapshot.AsReadOnly(); ValidateMergedCellGrid(Cells); + ValidateCellNamedDestinationNames(Cells); Rows = CreateTextRows(Cells); ColumnCount = GetColumnCount(Cells); } @@ -53,6 +54,7 @@ public TableBlock(System.Collections.Generic.IEnumerable rows, P Cells = cellSnapshot.AsReadOnly(); ValidateMergedCellGrid(Cells); + ValidateCellNamedDestinationNames(Cells); Rows = CreateTextRows(Cells); ColumnCount = GetColumnCount(Cells); } @@ -88,6 +90,23 @@ private static void ValidateMergedCellGrid(System.Collections.Generic.IReadOnlyL } } + private static void ValidateCellNamedDestinationNames(System.Collections.Generic.IReadOnlyList> rows) { + var names = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); + for (int rowIndex = 0; rowIndex < rows.Count; rowIndex++) { + var row = rows[rowIndex]; + for (int cellIndex = 0; cellIndex < row.Count; cellIndex++) { + string? name = row[cellIndex].NamedDestinationName; + if (string.IsNullOrWhiteSpace(name)) { + continue; + } + + if (!names.Add(name!)) { + throw new System.ArgumentException("Table cell named destinations must be unique.", nameof(rows)); + } + } + } + } + private static int GetColumnCount(System.Collections.Generic.IReadOnlyList> rows) { int columnCount = 0; var activeRowSpans = new System.Collections.Generic.List(); diff --git a/OfficeIMO.Pdf/Model/TextFieldBlock.cs b/OfficeIMO.Pdf/Model/TextFieldBlock.cs index 3ac5763d7..c9c848ecb 100644 --- a/OfficeIMO.Pdf/Model/TextFieldBlock.cs +++ b/OfficeIMO.Pdf/Model/TextFieldBlock.cs @@ -9,8 +9,9 @@ internal sealed class TextFieldBlock : IPdfBlock { public double FontSize { get; } public double SpacingBefore { get; } public double SpacingAfter { get; } + public PdfFormFieldStyle Style { get; } - public TextFieldBlock(string name, double width, double height, string? value, PdfAlign align, double fontSize, double spacingBefore, double spacingAfter) { + public TextFieldBlock(string name, double width, double height, string? value, PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, PdfFormFieldStyle? style = null) { Guard.NotNullOrWhiteSpace(name, nameof(name)); Guard.Positive(width, nameof(width)); Guard.Positive(height, nameof(height)); @@ -27,5 +28,6 @@ public TextFieldBlock(string name, double width, double height, string? value, P FontSize = fontSize; SpacingBefore = spacingBefore; SpacingAfter = spacingAfter; + Style = style?.Clone() ?? new PdfFormFieldStyle(); } } diff --git a/OfficeIMO.Pdf/Model/TextRun.cs b/OfficeIMO.Pdf/Model/TextRun.cs index 3c548207b..5f78b7489 100644 --- a/OfficeIMO.Pdf/Model/TextRun.cs +++ b/OfficeIMO.Pdf/Model/TextRun.cs @@ -16,6 +16,12 @@ public sealed class TextRun { public bool Italic { get; } /// Run foreground color (if any). public PdfColor? Color { get; } + /// Optional run background color, useful for highlights. + public PdfColor? BackgroundColor { get; } + /// Optional font size for this run. When null, the paragraph font size is used. + public double? FontSize { get; } + /// Optional standard PDF font for this run. When null, the paragraph/document font is used. + public PdfStandardFont? Font { get; } /// Optional hyperlink URI associated with this run. public string? LinkUri { get; } /// Optional named destination associated with this run. @@ -36,17 +42,26 @@ public sealed class TextRun { /// Run color or null to use defaults. /// Whether to render italic. /// Whether to render strikethrough. + /// Optional run font size in points. + /// Optional standard PDF font for this run. /// Optional absolute URI for link annotation. /// Optional link annotation contents; defaults to the run text when omitted. /// Baseline placement for this run. /// Optional named destination for an internal document link annotation. /// Leader fill to render when the run text is a tab character. /// Alignment to use when the run text is a tab character. - public TextRun(string text, bool bold = false, bool underline = false, PdfColor? color = null, bool italic = false, bool strike = false, string? linkUri = null, string? linkContents = null, PdfTextBaseline baseline = PdfTextBaseline.Normal, string? linkDestinationName = null, PdfTabLeaderStyle tabLeader = PdfTabLeaderStyle.None, PdfTabAlignment tabAlignment = PdfTabAlignment.Left) { + /// Optional run background color. + public TextRun(string text, bool bold = false, bool underline = false, PdfColor? color = null, bool italic = false, bool strike = false, double? fontSize = null, PdfStandardFont? font = null, string? linkUri = null, string? linkContents = null, PdfTextBaseline baseline = PdfTextBaseline.Normal, string? linkDestinationName = null, PdfTabLeaderStyle tabLeader = PdfTabLeaderStyle.None, PdfTabAlignment tabAlignment = PdfTabAlignment.Left, PdfColor? backgroundColor = null) { Guard.NotNull(text, nameof(text)); Guard.TextBaseline(baseline, nameof(baseline)); Guard.TabLeaderStyle(tabLeader, nameof(tabLeader)); Guard.TabAlignment(tabAlignment, nameof(tabAlignment)); + if (fontSize.HasValue) { + Guard.Positive(fontSize.Value, nameof(fontSize)); + } + if (font.HasValue) { + Guard.StandardFont(font.Value, nameof(font), "Text run font must be one of the supported standard PDF fonts."); + } if (linkUri != null && linkDestinationName != null) { throw new System.ArgumentException("A text run link can target either a URI or a bookmark, not both.", nameof(linkDestinationName)); } @@ -80,6 +95,9 @@ public TextRun(string text, bool bold = false, bool underline = false, PdfColor? Italic = italic; Strike = strike; Color = color; + BackgroundColor = backgroundColor; + FontSize = fontSize; + Font = font; LinkUri = linkUri; LinkDestinationName = linkDestinationName; LinkContents = hasLinkTarget ? linkContents ?? text : null; @@ -89,27 +107,27 @@ public TextRun(string text, bool bold = false, bool underline = false, PdfColor? } /// Create a normal (unstyled) run. - public static TextRun Normal(string text, PdfColor? color = null) => new TextRun(text, bold: false, underline: false, color: color, italic: false, strike: false); + public static TextRun Normal(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: false, underline: false, color: color, italic: false, strike: false, fontSize: fontSize, font: font, backgroundColor: backgroundColor); /// Create an explicit line-break run. public static TextRun LineBreak() => new TextRun("\n", bold: false, underline: false, color: null, italic: false, strike: false); /// Create an explicit paragraph tab run. public static TextRun Tab(PdfTabLeaderStyle leader = PdfTabLeaderStyle.None, PdfTabAlignment alignment = PdfTabAlignment.Left) => new TextRun("\t", tabLeader: leader, tabAlignment: alignment); /// Create a bold run. - public static TextRun Bolded(string text, PdfColor? color = null) => new TextRun(text, bold: true, underline: false, color: color, italic: false, strike: false); + public static TextRun Bolded(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: true, underline: false, color: color, italic: false, strike: false, fontSize: fontSize, font: font, backgroundColor: backgroundColor); /// Create an underlined run. - public static TextRun Underlined(string text, PdfColor? color = null) => new TextRun(text, bold: false, underline: true, color: color, italic: false, strike: false); + public static TextRun Underlined(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: false, underline: true, color: color, italic: false, strike: false, fontSize: fontSize, font: font, backgroundColor: backgroundColor); /// Create an italic run. - public static TextRun Italicized(string text, PdfColor? color = null) => new TextRun(text, bold: false, underline: false, color: color, italic: true, strike: false); + public static TextRun Italicized(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: false, underline: false, color: color, italic: true, strike: false, fontSize: fontSize, font: font, backgroundColor: backgroundColor); /// Create a bold and underlined run. - public static TextRun BoldUnderlined(string text, PdfColor? color = null) => new TextRun(text, bold: true, underline: true, color: color, italic: false, strike: false); + public static TextRun BoldUnderlined(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: true, underline: true, color: color, italic: false, strike: false, fontSize: fontSize, font: font, backgroundColor: backgroundColor); /// Create a bold and italic run. - public static TextRun BoldItalic(string text, PdfColor? color = null) => new TextRun(text, bold: true, underline: false, color: color, italic: true, strike: false); + public static TextRun BoldItalic(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: true, underline: false, color: color, italic: true, strike: false, fontSize: fontSize, font: font, backgroundColor: backgroundColor); /// Create a strikethrough run. - public static TextRun Strikethrough(string text, PdfColor? color = null) => new TextRun(text, bold: false, underline: false, color: color, italic: false, strike: true); + public static TextRun Strikethrough(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: false, underline: false, color: color, italic: false, strike: true, fontSize: fontSize, font: font, backgroundColor: backgroundColor); /// Create a superscript run. - public static TextRun Superscript(string text, PdfColor? color = null) => new TextRun(text, bold: false, underline: false, color: color, italic: false, strike: false, baseline: PdfTextBaseline.Superscript); + public static TextRun Superscript(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: false, underline: false, color: color, italic: false, strike: false, fontSize: fontSize, font: font, baseline: PdfTextBaseline.Superscript, backgroundColor: backgroundColor); /// Create a subscript run. - public static TextRun Subscript(string text, PdfColor? color = null) => new TextRun(text, bold: false, underline: false, color: color, italic: false, strike: false, baseline: PdfTextBaseline.Subscript); + public static TextRun Subscript(string text, PdfColor? color = null, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) => new TextRun(text, bold: false, underline: false, color: color, italic: false, strike: false, fontSize: fontSize, font: font, baseline: PdfTextBaseline.Subscript, backgroundColor: backgroundColor); /// Create a hyperlink run that points to a URI. /// Link text. /// Absolute URI. @@ -117,9 +135,12 @@ public TextRun(string text, bool bold = false, bool underline = false, PdfColor? /// Whether to underline the link text. /// Optional link annotation contents. /// Baseline placement for this run. - public static TextRun Link(string text, string uri, PdfColor? color = null, bool underline = true, string? contents = null, PdfTextBaseline baseline = PdfTextBaseline.Normal) { + /// Optional run font size in points. + /// Optional run background color. + /// Optional standard font family for this run. + public static TextRun Link(string text, string uri, PdfColor? color = null, bool underline = true, string? contents = null, PdfTextBaseline baseline = PdfTextBaseline.Normal, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) { Guard.AbsoluteUri(uri, nameof(uri)); - return new TextRun(text, bold: false, underline: underline, color: color, italic: false, strike: false, linkUri: uri, linkContents: contents, baseline: baseline); + return new TextRun(text, bold: false, underline: underline, color: color, italic: false, strike: false, fontSize: fontSize, font: font, linkUri: uri, linkContents: contents, baseline: baseline, backgroundColor: backgroundColor); } /// Create a hyperlink run that points to a document bookmark. /// Link text. @@ -128,8 +149,11 @@ public static TextRun Link(string text, string uri, PdfColor? color = null, bool /// Whether to underline the link text. /// Optional link annotation contents. /// Baseline placement for this run. - public static TextRun LinkToBookmark(string text, string bookmarkName, PdfColor? color = null, bool underline = true, string? contents = null, PdfTextBaseline baseline = PdfTextBaseline.Normal) { + /// Optional run font size in points. + /// Optional run background color. + /// Optional standard font family for this run. + public static TextRun LinkToBookmark(string text, string bookmarkName, PdfColor? color = null, bool underline = true, string? contents = null, PdfTextBaseline baseline = PdfTextBaseline.Normal, double? fontSize = null, PdfColor? backgroundColor = null, PdfStandardFont? font = null) { Guard.NotNullOrWhiteSpace(bookmarkName, nameof(bookmarkName)); - return new TextRun(text, bold: false, underline: underline, color: color, italic: false, strike: false, linkContents: contents, baseline: baseline, linkDestinationName: bookmarkName); + return new TextRun(text, bold: false, underline: underline, color: color, italic: false, strike: false, fontSize: fontSize, font: font, linkContents: contents, baseline: baseline, linkDestinationName: bookmarkName, backgroundColor: backgroundColor); } } diff --git a/OfficeIMO.Pdf/Options/PdfOptions.cs b/OfficeIMO.Pdf/Options/PdfOptions.cs index 86213e415..b724c7c98 100644 --- a/OfficeIMO.Pdf/Options/PdfOptions.cs +++ b/OfficeIMO.Pdf/Options/PdfOptions.cs @@ -30,6 +30,18 @@ public sealed class PdfOptions { private string? _evenPageFooterLeftFormat; private string? _evenPageFooterCenterFormat; private string? _evenPageFooterRightFormat; + private System.Collections.Generic.List? _headerImages; + private System.Collections.Generic.List? _firstPageHeaderImages; + private System.Collections.Generic.List? _evenPageHeaderImages; + private System.Collections.Generic.List? _footerImages; + private System.Collections.Generic.List? _firstPageFooterImages; + private System.Collections.Generic.List? _evenPageFooterImages; + private System.Collections.Generic.List? _headerShapes; + private System.Collections.Generic.List? _firstPageHeaderShapes; + private System.Collections.Generic.List? _evenPageHeaderShapes; + private System.Collections.Generic.List? _footerShapes; + private System.Collections.Generic.List? _firstPageFooterShapes; + private System.Collections.Generic.List? _evenPageFooterShapes; private PdfStandardFont _defaultFont = PdfStandardFont.Helvetica; private PdfStandardFont _headerFont = PdfStandardFont.Helvetica; private PdfStandardFont _footerFont = PdfStandardFont.Helvetica; @@ -62,6 +74,8 @@ public PageSize PageSize { } /// Page orientation inferred from the current page size. public PdfPageOrientation PageOrientation => PageWidth > PageHeight ? PdfPageOrientation.Landscape : PdfPageOrientation.Portrait; + /// Optional page background color rendered behind all page content. + public PdfColor? BackgroundColor { get; set; } /// Left margin in points. Default 72 (1 inch). public double MarginLeft { get; set; } = 72; // 1 in /// Right margin in points. Default 72 (1 inch). @@ -260,8 +274,12 @@ public PdfOptions ApplyTheme(PdfTheme theme) { internal bool HasHeaderContent => (ShowHeader && HeaderFormat != null && HeaderFormat.Length > 0) || (_headerSegments != null && _headerSegments.Count > 0) || - HasHeaderZoneContent; - internal bool HasFooterContent => ShowPageNumbers || (_footerSegments != null && _footerSegments.Count > 0) || HasFooterZoneContent; + HasHeaderZoneContent || + HasHeaderImageContent; + internal bool HasFooterContent => ShowPageNumbers || + (_footerSegments != null && _footerSegments.Count > 0) || + HasFooterZoneContent || + HasFooterImageContent; internal bool HasHeaderZoneContent => !string.IsNullOrEmpty(_headerLeftFormat) || !string.IsNullOrEmpty(_headerCenterFormat) || @@ -286,7 +304,31 @@ public PdfOptions ApplyTheme(PdfTheme theme) { !string.IsNullOrEmpty(_evenPageFooterLeftFormat) || !string.IsNullOrEmpty(_evenPageFooterCenterFormat) || !string.IsNullOrEmpty(_evenPageFooterRightFormat); + internal bool HasHeaderImageContent => _headerImages != null && _headerImages.Count > 0; + internal bool HasFirstPageHeaderImageContent => _firstPageHeaderImages != null && _firstPageHeaderImages.Count > 0; + internal bool HasEvenPageHeaderImageContent => _evenPageHeaderImages != null && _evenPageHeaderImages.Count > 0; + internal bool HasFooterImageContent => _footerImages != null && _footerImages.Count > 0; + internal bool HasFirstPageFooterImageContent => _firstPageFooterImages != null && _firstPageFooterImages.Count > 0; + internal bool HasEvenPageFooterImageContent => _evenPageFooterImages != null && _evenPageFooterImages.Count > 0; internal bool HasHeaderContentForPage(int pageNumber) { + if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { + return (FirstPageHeaderFormat != null && FirstPageHeaderFormat.Length > 0) || + (_firstPageHeaderSegments != null && _firstPageHeaderSegments.Count > 0) || + HasFirstPageHeaderZoneContent || + HasFirstPageHeaderImageContent; + } + + if (IsEvenPageVariant(pageNumber)) { + return (EvenPageHeaderFormat != null && EvenPageHeaderFormat.Length > 0) || + (_evenPageHeaderSegments != null && _evenPageHeaderSegments.Count > 0) || + HasEvenPageHeaderZoneContent || + HasEvenPageHeaderImageContent; + } + + return HasHeaderContent; + } + + internal bool HasHeaderTextContentForPage(int pageNumber) { if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { return (FirstPageHeaderFormat != null && FirstPageHeaderFormat.Length > 0) || (_firstPageHeaderSegments != null && _firstPageHeaderSegments.Count > 0) || @@ -299,10 +341,30 @@ internal bool HasHeaderContentForPage(int pageNumber) { HasEvenPageHeaderZoneContent; } - return HasHeaderContent; + return (ShowHeader && HeaderFormat != null && HeaderFormat.Length > 0) || + (_headerSegments != null && _headerSegments.Count > 0) || + HasHeaderZoneContent; } internal bool HasFooterContentForPage(int pageNumber) { + if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { + return (FirstPageFooterFormat != null && FirstPageFooterFormat.Length > 0) || + (_firstPageFooterSegments != null && _firstPageFooterSegments.Count > 0) || + HasFirstPageFooterZoneContent || + HasFirstPageFooterImageContent; + } + + if (IsEvenPageVariant(pageNumber)) { + return (EvenPageFooterFormat != null && EvenPageFooterFormat.Length > 0) || + (_evenPageFooterSegments != null && _evenPageFooterSegments.Count > 0) || + HasEvenPageFooterZoneContent || + HasEvenPageFooterImageContent; + } + + return HasFooterContent; + } + + internal bool HasFooterTextContentForPage(int pageNumber) { if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { return (FirstPageFooterFormat != null && FirstPageFooterFormat.Length > 0) || (_firstPageFooterSegments != null && _firstPageFooterSegments.Count > 0) || @@ -315,7 +377,9 @@ internal bool HasFooterContentForPage(int pageNumber) { HasEvenPageFooterZoneContent; } - return HasFooterContent; + return ShowPageNumbers || + (_footerSegments != null && _footerSegments.Count > 0) || + HasFooterZoneContent; } internal string GetHeaderFormatForPage(int pageNumber) { @@ -346,6 +410,30 @@ internal string GetHeaderFormatForPage(int pageNumber) { return (_headerLeftFormat, _headerCenterFormat, _headerRightFormat); } + internal System.Collections.Generic.IReadOnlyList GetHeaderImagesForPage(int pageNumber) { + if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { + return _firstPageHeaderImages != null ? _firstPageHeaderImages : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + if (IsEvenPageVariant(pageNumber)) { + return _evenPageHeaderImages != null ? _evenPageHeaderImages : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + return _headerImages != null ? _headerImages : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + internal System.Collections.Generic.IReadOnlyList GetHeaderShapesForPage(int pageNumber) { + if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { + return _firstPageHeaderShapes != null ? _firstPageHeaderShapes : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + if (IsEvenPageVariant(pageNumber)) { + return _evenPageHeaderShapes != null ? _evenPageHeaderShapes : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + return _headerShapes != null ? _headerShapes : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + internal string GetFooterFormatForPage(int pageNumber) { if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { return FirstPageFooterFormat; @@ -374,6 +462,30 @@ internal string GetFooterFormatForPage(int pageNumber) { return (_footerLeftFormat, _footerCenterFormat, _footerRightFormat); } + internal System.Collections.Generic.IReadOnlyList GetFooterImagesForPage(int pageNumber) { + if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { + return _firstPageFooterImages != null ? _firstPageFooterImages : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + if (IsEvenPageVariant(pageNumber)) { + return _evenPageFooterImages != null ? _evenPageFooterImages : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + return _footerImages != null ? _footerImages : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + internal System.Collections.Generic.IReadOnlyList GetFooterShapesForPage(int pageNumber) { + if (pageNumber == 1 && DifferentFirstPageHeaderFooter) { + return _firstPageFooterShapes != null ? _firstPageFooterShapes : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + if (IsEvenPageVariant(pageNumber)) { + return _evenPageFooterShapes != null ? _evenPageFooterShapes : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + + return _footerShapes != null ? _footerShapes : (System.Collections.Generic.IReadOnlyList)System.Array.Empty(); + } + private bool IsEvenPageVariant(int pageNumber) => DifferentOddAndEvenPagesHeaderFooter && pageNumber > 0 && pageNumber % 2 == 0; internal PdfParagraphStyle? DefaultParagraphStyleSnapshot => _defaultParagraphStyle; @@ -398,6 +510,7 @@ public PdfOptions Clone() { var clone = new PdfOptions { PageWidth = PageWidth, PageHeight = PageHeight, + BackgroundColor = BackgroundColor, MarginLeft = MarginLeft, MarginRight = MarginRight, MarginTop = MarginTop, @@ -465,13 +578,51 @@ public PdfOptions Clone() { _firstPageFooterRightFormat = _firstPageFooterRightFormat, _evenPageFooterLeftFormat = _evenPageFooterLeftFormat, _evenPageFooterCenterFormat = _evenPageFooterCenterFormat, - _evenPageFooterRightFormat = _evenPageFooterRightFormat + _evenPageFooterRightFormat = _evenPageFooterRightFormat, + _headerImages = CloneHeaderFooterImages(_headerImages), + _firstPageHeaderImages = CloneHeaderFooterImages(_firstPageHeaderImages), + _evenPageHeaderImages = CloneHeaderFooterImages(_evenPageHeaderImages), + _footerImages = CloneHeaderFooterImages(_footerImages), + _firstPageFooterImages = CloneHeaderFooterImages(_firstPageFooterImages), + _evenPageFooterImages = CloneHeaderFooterImages(_evenPageFooterImages), + _headerShapes = CloneHeaderFooterShapes(_headerShapes), + _firstPageHeaderShapes = CloneHeaderFooterShapes(_firstPageHeaderShapes), + _evenPageHeaderShapes = CloneHeaderFooterShapes(_evenPageHeaderShapes), + _footerShapes = CloneHeaderFooterShapes(_footerShapes), + _firstPageFooterShapes = CloneHeaderFooterShapes(_firstPageFooterShapes), + _evenPageFooterShapes = CloneHeaderFooterShapes(_evenPageFooterShapes) }; clone._pageNumberStart = _pageNumberStart; clone._hasExplicitPageNumberStart = _hasExplicitPageNumberStart; return clone; } + private static System.Collections.Generic.List? CloneHeaderFooterImages(System.Collections.Generic.List? images) { + if (images == null) { + return null; + } + + var clone = new System.Collections.Generic.List(images.Count); + foreach (PdfHeaderFooterImage image in images) { + clone.Add(image.Clone()); + } + + return clone; + } + + private static System.Collections.Generic.List? CloneHeaderFooterShapes(System.Collections.Generic.List? shapes) { + if (shapes == null) { + return null; + } + + var clone = new System.Collections.Generic.List(shapes.Count); + foreach (PdfHeaderFooterShape shape in shapes) { + clone.Add(shape.Clone()); + } + + return clone; + } + internal void ClearPageNumberStartOverride() { _hasExplicitPageNumberStart = false; } @@ -502,6 +653,12 @@ internal void ClearHeaderZonesForCompose() { _headerRightFormat = null; } + internal void AddHeaderImageForCompose(PdfHeaderFooterImage image) { + Guard.NotNull(image, nameof(image)); + ShowHeader = true; + (_headerImages ??= new System.Collections.Generic.List()).Add(image.Clone()); + } + internal void SetFirstPageHeaderZonesForCompose(string? left, string? center, string? right) { ValidateZones(left, center, right, nameof(left)); ClearFirstPageHeaderSegmentsForCompose(); @@ -518,6 +675,12 @@ internal void ClearFirstPageHeaderZonesForCompose() { _firstPageHeaderRightFormat = null; } + internal void AddFirstPageHeaderImageForCompose(PdfHeaderFooterImage image) { + Guard.NotNull(image, nameof(image)); + DifferentFirstPageHeaderFooter = true; + (_firstPageHeaderImages ??= new System.Collections.Generic.List()).Add(image.Clone()); + } + internal void SetEvenPageHeaderZonesForCompose(string? left, string? center, string? right) { ValidateZones(left, center, right, nameof(left)); ClearEvenPageHeaderSegmentsForCompose(); @@ -534,6 +697,32 @@ internal void ClearEvenPageHeaderZonesForCompose() { _evenPageHeaderRightFormat = null; } + internal void AddEvenPageHeaderImageForCompose(PdfHeaderFooterImage image) { + Guard.NotNull(image, nameof(image)); + DifferentOddAndEvenPagesHeaderFooter = true; + (_evenPageHeaderImages ??= new System.Collections.Generic.List()).Add(image.Clone()); + } + + internal void AddHeaderShapeForCompose(PdfHeaderFooterShape shape) { + Guard.NotNull(shape, nameof(shape)); + ShowHeader = true; + (_headerShapes ??= new System.Collections.Generic.List()).Add(shape.Clone()); + } + + internal void AddFirstPageHeaderShapeForCompose(PdfHeaderFooterShape shape) { + Guard.NotNull(shape, nameof(shape)); + ShowHeader = true; + DifferentFirstPageHeaderFooter = true; + (_firstPageHeaderShapes ??= new System.Collections.Generic.List()).Add(shape.Clone()); + } + + internal void AddEvenPageHeaderShapeForCompose(PdfHeaderFooterShape shape) { + Guard.NotNull(shape, nameof(shape)); + ShowHeader = true; + DifferentOddAndEvenPagesHeaderFooter = true; + (_evenPageHeaderShapes ??= new System.Collections.Generic.List()).Add(shape.Clone()); + } + internal System.Collections.Generic.List ResetFirstPageHeaderSegmentsForCompose() { _firstPageHeaderSegments = new System.Collections.Generic.List(); DifferentFirstPageHeaderFooter = true; @@ -579,6 +768,11 @@ internal void ClearFooterZonesForCompose() { _footerRightFormat = null; } + internal void AddFooterImageForCompose(PdfHeaderFooterImage image) { + Guard.NotNull(image, nameof(image)); + (_footerImages ??= new System.Collections.Generic.List()).Add(image.Clone()); + } + internal void SetFirstPageFooterZonesForCompose(string? left, string? center, string? right) { ValidateZones(left, center, right, nameof(left)); ClearFirstPageFooterSegmentsForCompose(); @@ -595,6 +789,12 @@ internal void ClearFirstPageFooterZonesForCompose() { _firstPageFooterRightFormat = null; } + internal void AddFirstPageFooterImageForCompose(PdfHeaderFooterImage image) { + Guard.NotNull(image, nameof(image)); + DifferentFirstPageHeaderFooter = true; + (_firstPageFooterImages ??= new System.Collections.Generic.List()).Add(image.Clone()); + } + internal void SetEvenPageFooterZonesForCompose(string? left, string? center, string? right) { ValidateZones(left, center, right, nameof(left)); ClearEvenPageFooterSegmentsForCompose(); @@ -611,6 +811,29 @@ internal void ClearEvenPageFooterZonesForCompose() { _evenPageFooterRightFormat = null; } + internal void AddEvenPageFooterImageForCompose(PdfHeaderFooterImage image) { + Guard.NotNull(image, nameof(image)); + DifferentOddAndEvenPagesHeaderFooter = true; + (_evenPageFooterImages ??= new System.Collections.Generic.List()).Add(image.Clone()); + } + + internal void AddFooterShapeForCompose(PdfHeaderFooterShape shape) { + Guard.NotNull(shape, nameof(shape)); + (_footerShapes ??= new System.Collections.Generic.List()).Add(shape.Clone()); + } + + internal void AddFirstPageFooterShapeForCompose(PdfHeaderFooterShape shape) { + Guard.NotNull(shape, nameof(shape)); + DifferentFirstPageHeaderFooter = true; + (_firstPageFooterShapes ??= new System.Collections.Generic.List()).Add(shape.Clone()); + } + + internal void AddEvenPageFooterShapeForCompose(PdfHeaderFooterShape shape) { + Guard.NotNull(shape, nameof(shape)); + DifferentOddAndEvenPagesHeaderFooter = true; + (_evenPageFooterShapes ??= new System.Collections.Generic.List()).Add(shape.Clone()); + } + internal System.Collections.Generic.List ResetFirstPageFooterSegmentsForCompose() { _firstPageFooterSegments = new System.Collections.Generic.List(); DifferentFirstPageHeaderFooter = true; diff --git a/OfficeIMO.Pdf/README.md b/OfficeIMO.Pdf/README.md index d5840b613..a586896f1 100644 --- a/OfficeIMO.Pdf/README.md +++ b/OfficeIMO.Pdf/README.md @@ -20,7 +20,11 @@ Goals Relationship to OfficeIMO.Word.Pdf ---------------------------------- -`OfficeIMO.Word.Pdf` currently uses QuestPDF and SkiaSharp for Word-to-PDF export. Treat that package as a bridge. +`OfficeIMO.Word.Pdf` now defaults to the first-party `OfficeIMO.Pdf` engine for Word-to-PDF export. + +The default first-party path maps basic Word sections, page setup with explicit PDF page geometry preserved unless `PdfSaveOptions.Orientation` is set, Word document background color, Word section columns with explicit and inline paragraph column breaks plus separator lines, page breaks, headings including linked headings, paragraphs, common Word/PDF font-family requests to standard Helvetica, Times, and Courier PDF families, runs with isolated run color, font-size, superscript/subscript baseline, text-wrapping breaks, and highlight/background state, paragraph spacing/indents, simple tab stops with leaders/alignment, keep-with-next/keep-lines/widow-control flags, simple shaded and uniform/non-uniform bordered paragraphs, Word horizontal lines and paragraph top/bottom border rules, simple level-0 bullet/decimal lists including rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, footnote/endnote markers, tables with supported Word table style presets, rich text runs inside table cells, default and per-cell table margins, table cell spacing, table-level borders, uniform and non-uniform row heights, row-level break policies, preferred DXA table widths, explicit autofit-to-contents tables, simple cell fills, uniform and non-uniform cell borders, left/center/right table placement, uniform column and non-uniform per-cell horizontal/vertical alignment, simple merged cells, separated first-row visual table styling and repeated leading table header rows, and linked cells including linked merged cells, paragraph-aligned images, simple VML shapes and the DrawingML preset flow shapes exposed by `WordShape`, simple text boxes, simple inline body/table/header/footer text content controls, simple body, table-cell, header, and footer picture content controls as first-party PDF images, simple body/table-cell/header/footer repeating-section text items, simple body and table-cell Word check boxes as first-party AcroForm check boxes, simple body-level and table-cell Word dropdown, combo box, and date picker controls as first-party AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple default/first/even header and footer text/images/shapes with left/center/right paragraph alignment, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, and simple header/footer table-cell text, image, and shape zones mapped to first-party zones, simple footnote/endnote markers with end-of-section note text, metadata, and page-number footer settings including Word section page-number starts/styles into `OfficeIMO.Pdf`. + +Simple Word OMML equations with extractable math text are exported as static first-party PDF text in body paragraphs, table cells, headers, and footers; equations without extractable text still produce `PdfSaveOptions.Warnings`. The strategic target is for `OfficeIMO.Pdf` to become good enough that Word, Excel, and PowerPoint exporters can render through the first-party engine without bringing QuestPDF, SkiaSharp, iText, or other runtime PDF dependencies into the core PDF package. @@ -55,27 +59,27 @@ Current Feature Set Generation: -- Automatic page flow with configurable page size, inch/centimeter-based page-size helpers, portrait/landscape orientation helpers, margins, inch/centimeter-based margin helpers, reusable Word-compatible `PageMargins` presets, and default text style through `PdfOptions`, document defaults, or page-scoped composition, including long rich paragraph and oversized list-item continuation across pages. -- Headings with Word-like reusable `PdfHeadingStyle` / `PdfHeadingStyles` typography and rhythm defaults, spacing before/after with Word-like spacing-before suppression at fresh page/column starts, optional per-heading style/alignment/color overrides across direct, compose item/element, and row-column flows, and orphan prevention before following paragraphs; paragraphs; page breaks; hard line breaks in shared simple text wrapping for headings, list items, table cells, captions, and similar non-rich text; invisible `Spacer(...)` flow gaps for generic document rhythm without fake blank text; horizontal rules with reusable `PdfHorizontalRuleStyle` thickness, color, outer rhythm, and keep-with-next defaults; panels with reusable `PanelStyle` box, padding, alignment, color, keep-together, keep-with-next, and outer rhythm defaults; bullet and numbered lists with reusable `PdfListStyle` typography, indentation, marker gap, color, rhythm, keep-together, and keep-with-next page flow; rows/columns with a built-in Word-like gutter, reusable `PdfRowStyle` gutters, keep-together and keep-with-next page flow, and outer rhythm plus column-local item groups, headings, lists, panels, spacers, and compact tables; and simple top-level tables. Flow-object spacing-before is treated as separation between visible blocks and is suppressed at fresh page/column starts across lists, panels, rules, images, shapes, drawings, rows, paragraphs, headings, and tables. -- Rich paragraph runs with bold, italic, underline, strike, color, superscript/subscript baseline shifts, links with annotation contents metadata, explicit line breaks, left, center, right, and decimal paragraph tabs with optional dotted, hyphen, or underscore leaders, alignment, justification, proportional standard-font wrapping, configurable line height, left/right/first-line/hanging indents, spacing before/after with Word-like spacing-before suppression at fresh page/column starts, and Word-like keep-together, keep-with-next, and widow/orphan options for page-flow control. -- Table styling, default table styles through `PdfOptions.DefaultTableStyle` or `PdfDoc.DefaultTableStyle(...)`, initial Word-like table presets (`TableNormal`, `TableGrid`, `TableGridLight`, `PlainTable1`, `GridTable1Light`, `ListTable1Light`, plus Accent1-6 variants with Word default theme border, separator, and soft band colors for the light grid/list styles) with name-based resolution through `TableStyles.FromWordTableStyle(...)`, canonical name normalization through `TableStyles.GetCanonicalWordStyleName(...)` / `TryGetCanonicalWordStyleName(...)`, clean display names through `TableStyles.CanonicalWordStyleNames`, accepted input aliases through `TableStyles.SupportedWordStyleNames`, captions, row/header/footer separators, side-specific per-cell border overrides, body column fills, per-cell fills, header-safe body row striping, horizontal/vertical cell alignment, generic header/body/footer typography, cell line-height controls, symmetric and side-specific cell padding, configurable header/footer row counts with render-time bounds validation, minimum row height, table left indentation and max-width caps with left/center/right placement, table spacing before/after with Word-like spacing-before suppression at fresh page/column starts, keep-together, keep-with-next first-row preflight that honors configured column widths, and row-break page-flow controls, fixed/min/max column widths, relative column width weights, column-scoped style bounds validation for sizing/fills/horizontal and vertical alignment, OfficeIMO.Drawing-backed auto-fit column sizing with token minimums, initial `PdfTableCell` column spans, row spans, rectangular merged cells with combined-box alignment, overlong row-span validation, header/footer boundary validation for row-spanned cells, row-spanned explicit cell fills/borders, explicit cell fill/border coordinate bounds validation, explicit cell fill/border coordinates that skip row-span and column-span continuation slots, row/header/footer separators, body-column background fills that skip merged-cell continuation columns, row/background fills, and default table border grids that skip row-spanned and rectangular merged-cell interiors, and cell-owned URI links, including linked column/row-spanned cell annotations over the merged text frame in top-level and compose/row-column table flows, row height calculation, proportional standard-font wrapped cell text and captions, row-by-row pagination, oversized-row splitting, and repeated header rows. +- Automatic page flow with configurable page size, inch/centimeter-based page-size helpers, portrait/landscape orientation helpers, margins, inch/centimeter-based margin helpers, reusable Word-compatible `PageMargins` presets, optional full-page background color through `PdfOptions.BackgroundColor`, `PdfDoc.Background(...)`, or `PdfPageCompose.Background(...)`, and default text style through `PdfOptions`, document defaults, or page-scoped composition, including long rich paragraph and oversized list-item continuation across pages. +- Headings with Word-like reusable `PdfHeadingStyle` / `PdfHeadingStyles` typography and rhythm defaults, spacing before/after with Word-like spacing-before suppression at fresh page/column starts, optional per-heading style/alignment/color overrides across direct, compose item/element, and row-column flows, and orphan prevention before following paragraphs; paragraphs; page breaks; hard line breaks in shared simple text wrapping for headings, simple list items, table cells, captions, and similar non-rich text; invisible `Spacer(...)` flow gaps for generic document rhythm without fake blank text; horizontal rules with reusable `PdfHorizontalRuleStyle` thickness, color, outer rhythm, and keep-with-next defaults; panels with reusable `PanelStyle` box, uniform or side-specific borders, padding, alignment, color, keep-together, keep-with-next, and outer rhythm defaults; bullet and numbered lists with reusable `PdfListStyle` typography, indentation, marker gap, color, rhythm, keep-together, keep-with-next page flow, and rich `PdfListItem` runs plus per-item bookmark anchors through `RichBullets(...)` / `RichNumbered(...)` in top-level and row-column flows; rows/columns with a built-in Word-like gutter, reusable `PdfRowStyle` gutters, keep-together and keep-with-next page flow, and outer rhythm plus column-local item groups, headings, lists, panels, spacers, and compact tables; and simple top-level tables. Flow-object spacing-before is treated as separation between visible blocks and is suppressed at fresh page/column starts across lists, panels, rules, images, shapes, drawings, rows, paragraphs, headings, and tables. +- Rich paragraph runs with bold, italic, underline, strike, color, scoped standard PDF font family, scoped font size, scoped background/highlight fills, superscript/subscript baseline shifts, links with annotation contents metadata, explicit line breaks, left, center, right, and decimal paragraph tabs with optional dotted, hyphen, or underscore leaders, alignment, justification, proportional standard-font wrapping, configurable line height, left/right/first-line/hanging indents, spacing before/after with Word-like spacing-before suppression at fresh page/column starts, and Word-like keep-together, keep-with-next, and widow/orphan options for page-flow control. +- Table styling, default table styles through `PdfOptions.DefaultTableStyle` or `PdfDoc.DefaultTableStyle(...)`, rich `PdfTableCell` text runs with scoped color, bold/italic, underline/strike, font size, background/highlight, baseline, tabs, and links, initial Word-like table presets (`TableNormal`, `TableGrid`, `TableGridLight`, `PlainTable1`, `GridTable1Light`, `ListTable1Light`, plus Accent1-6 variants with Word default theme border, separator, and soft band colors for the light grid/list styles) with name-based resolution through `TableStyles.FromWordTableStyle(...)`, canonical name normalization through `TableStyles.GetCanonicalWordStyleName(...)` / `TryGetCanonicalWordStyleName(...)`, clean display names through `TableStyles.CanonicalWordStyleNames`, accepted input aliases through `TableStyles.SupportedWordStyleNames`, captions, row/header/footer separators, side-specific per-cell border overrides with independent side colors, widths, solid/dashed/dotted/dash-dot strokes, two-line borders, and diagonal-up/diagonal-down cell lines, body column fills, per-cell fills, per-cell data bars, per-cell vector icons, per-cell padding overrides, header-safe body row striping, column and per-cell horizontal/vertical cell alignment, generic header/body/footer typography, cell line-height controls, symmetric and side-specific cell padding, configurable cell spacing, configurable visual header/footer row counts with render-time bounds validation, optional repeated-header row count through `PdfTableStyle.RepeatHeaderRowCount`, table-wide and per-row minimum heights, table-wide and per-row row-break policies, table left indentation and max-width caps with left/center/right placement, table spacing before/after with Word-like spacing-before suppression at fresh page/column starts, keep-together, keep-with-next first-row preflight that honors configured column widths, and row-break page-flow controls, fixed/min/max column widths, relative column width weights, column-scoped style bounds validation for sizing/fills/horizontal and vertical alignment, OfficeIMO.Drawing-backed auto-fit column sizing with token minimums, initial `PdfTableCell` column spans, row spans, rectangular merged cells with combined-box alignment, overlong row-span validation, header/footer boundary validation for row-spanned cells, row-spanned explicit cell fills/borders, explicit cell fill/data-bar/icon/border/padding/alignment coordinate bounds validation, explicit cell fill/data-bar/icon/border coordinates that skip row-span and column-span continuation slots, row/header/footer separators, body-column background fills that skip merged-cell continuation columns, row/background fills, and default table border grids that skip row-spanned and rectangular merged-cell interiors, cell-owned URI or named-destination links, and cell-owned named-destination anchors through `PdfTableCell.NamedDestinationName` / `WithNamedDestination(...)`, including linked column/row-spanned cell annotations over the merged text frame in top-level and compose/row-column table flows, row height calculation, proportional standard-font wrapped cell text and captions, row-by-row pagination, oversized-row splitting, and repeated header rows. - Flow vector lines, rectangles, rounded rectangles, ellipses, polygons, paths, and reusable drawing scenes through shared `OfficeIMO.Drawing` descriptors, with solid fill, two-stop linear gradient fill, simple offset shadow, stroke, stroke width, dash style, line cap, line join, fill/stroke opacity, affine transforms, clipping paths, optional URI link annotations on generic shape/drawing blocks and vector convenience helpers, and reusable `PdfDrawingStyle` alignment, outer rhythm, and keep-with-next defaults. -- JPEG and simple non-interlaced 8-bit grayscale/grayscale-alpha/RGB/RGBA PNG image placement, including PNG alpha soft masks, reusable `PdfImageStyle` alignment, fit, clipping, outer rhythm, and keep-with-next defaults, first-party image metadata detection, shared `OfficeImageFit` stretch/contain/cover fitting, and shared `OfficeClipPath` clipping through `OfficeIMO.Drawing`. -- Header/footer literal text formats through `PdfOptions`, document-level `PdfDoc.Header(...)` / `PdfDoc.Footer(...)`, or page-scoped `PdfPageCompose.Header(...)` / `Footer(...)`, with visible page number tokens that continue across flows by default, configurable visible page-number starts through `PageNumberStart(...)`, decimal/roman/alphabetic page-number styles through `PageNumberStyle(...)`, left/center/right text zones through `Zones(...)`, `FirstPageZones(...)`, and `EvenPagesZones(...)`, segment builders for composed header/footer text, configurable fonts, sizes, text colors, and margin-relative offsets, plus section-local first-page and odd/even header/footer overrides for Word-like section, cover-page, and report flows. +- JPEG and simple non-interlaced 8-bit grayscale/grayscale-alpha/RGB/RGBA PNG image placement, including PNG alpha soft masks, reusable `PdfImageStyle` alignment, fit, clipping, outer rhythm, and keep-with-next defaults, first-party image metadata detection, shared `OfficeImageFit` stretch/contain/cover fitting, shared `OfficeClipPath` clipping through `OfficeIMO.Drawing`, and table-cell images through `PdfTableCell.WithImages(...)`. +- Header/footer literal text formats through `PdfOptions`, document-level `PdfDoc.Header(...)` / `PdfDoc.Footer(...)`, or page-scoped `PdfPageCompose.Header(...)` / `Footer(...)`, with visible page number tokens that continue across flows by default, configurable visible page-number starts through `PageNumberStart(...)`, decimal/roman/alphabetic page-number styles through `PageNumberStyle(...)`, left/center/right text zones through `Zones(...)`, `FirstPageZones(...)`, and `EvenPagesZones(...)`, segment builders for composed header/footer text, simple header/footer images through `Image(...)`, `FirstPageImage(...)`, and `EvenPagesImage(...)`, configurable fonts, sizes, text colors, and margin-relative offsets, plus section-local first-page and odd/even header/footer overrides for Word-like section, cover-page, and report flows. - Metadata: title, author, subject, and keywords. - Optional reusable theme bundle through `PdfTheme` for default text, heading, list, panel, paragraph, horizontal rule, image, drawing, row, and table styles, applicable through `PdfOptions.ApplyTheme(...)`, `PdfDoc.Theme(...)`, or `PdfPageCompose.Theme(...)`; `PdfTheme.WordLike()` provides a generic opt-in document rhythm without introducing invoice/report-specific engine APIs. - Built-in Helvetica body/header/footer defaults for readable proportional no-options documents, with optional reusable default text style overrides through `PdfTextStyle` or fluent `PdfDoc.DefaultTextStyle(...)` / `PdfPageCompose.DefaultTextStyle(...)` configuration. - Optional default heading styles for Word-like H1/H2/H3 typography, color, spacing before/after with fresh page/column spacing-before suppression, and keep-with-next behavior through `PdfOptions.DefaultHeadingStyles`, `PdfOptions.SetDefaultHeadingStyle(...)`, `PdfDoc.DefaultHeadingStyle(...)`, `PdfPageCompose.DefaultHeadingStyle(...)`, or per-heading `style:` overrides; compose item/element and row-column heading helpers also expose explicit `align` and `color` overloads for local visual control without report-specific APIs. - Optional default list style for Word-like bullet and numbered list font size, line height, left indent, marker gap, color, spacing before/after with fresh page/column spacing-before suppression, inter-item rhythm, keep-together, and keep-with-next page flow through `PdfOptions.DefaultListStyle`, `PdfDoc.DefaultListStyle(...)`, `PdfPageCompose.DefaultListStyle(...)`, or per-list `style:` overrides. -- Optional default panel style for Word-like boxed paragraph appearance, rhythm with fresh page/column spacing-before suppression, keep-together, and keep-with-next page flow through `PdfOptions.DefaultPanelStyle`, `PdfDoc.DefaultPanelStyle(...)`, `PdfPageCompose.DefaultPanelStyle(...)`, or per-panel `style:` overrides. +- Optional default panel style for Word-like boxed paragraph appearance, including uniform or side-specific `PdfPanelBorder` sides, rhythm with fresh page/column spacing-before suppression, keep-together, and keep-with-next page flow through `PdfOptions.DefaultPanelStyle`, `PdfDoc.DefaultPanelStyle(...)`, `PdfPageCompose.DefaultPanelStyle(...)`, or per-panel `style:` overrides. - Optional default horizontal rule style for Word-like separators, rhythm with fresh page/column spacing-before suppression, and keep-with-next page flow through `PdfOptions.DefaultHorizontalRuleStyle`, `PdfDoc.DefaultHorizontalRuleStyle(...)`, `PdfPageCompose.DefaultHorizontalRuleStyle(...)`, or per-rule `style:` overrides. - Optional default image style for Word-like image placement, fitting, clipping, rhythm with fresh page/column spacing-before suppression, and keep-with-next page flow through `PdfOptions.DefaultImageStyle`, `PdfDoc.DefaultImageStyle(...)`, `PdfPageCompose.DefaultImageStyle(...)`, or per-image `style:` overrides. - Optional default drawing style for Word-like shape and drawing-scene placement, rhythm with fresh page/column spacing-before suppression, and keep-with-next page flow through `PdfOptions.DefaultDrawingStyle`, `PdfDoc.DefaultDrawingStyle(...)`, `PdfPageCompose.DefaultDrawingStyle(...)`, or per-shape/per-drawing `style:` overrides. -- Optional default row style for Word-like column gutters, row-level spacing with fresh page/column spacing-before suppression, keep-together, and keep-with-next page flow through `PdfOptions.DefaultRowStyle`, `PdfDoc.DefaultRowStyle(...)`, `PdfPageCompose.DefaultRowStyle(...)`, `PdfTheme.RowStyle`, or per-row `Style(...)` overrides; multi-column rows use a built-in gutter unless callers explicitly set `Gap(0)` or `PdfRowStyle { Gap = 0 }`. +- Optional default row style for Word-like column gutters, optional vertical column separators through `PdfRowStyle.ColumnSeparatorColor` / `ColumnSeparatorWidth` or per-row `ColumnSeparator(...)`, row-level spacing with fresh page/column spacing-before suppression, keep-together, and keep-with-next page flow through `PdfOptions.DefaultRowStyle`, `PdfDoc.DefaultRowStyle(...)`, `PdfPageCompose.DefaultRowStyle(...)`, `PdfTheme.RowStyle`, or per-row `Style(...)` overrides; multi-column rows use a built-in gutter unless callers explicitly set `Gap(0)` or `PdfRowStyle { Gap = 0 }`. - Optional default paragraph style for Word-like reusable typography and page-flow settings when individual paragraphs do not provide their own style, either through `PdfOptions.DefaultParagraphStyle` or the fluent `PdfDoc.DefaultParagraphStyle(...)` setter. -- Page/section-scoped flow can be created through `PdfDoc.Page(...)`, `PdfDoc.Section(...)`, `PdfDoc.Compose(...Page...)`, or `PdfDoc.Compose(...Section...)`; page content can add direct `Item(...)` groups, nested element groups, `Spacer(...)` rhythm blocks, and `PageBreak()` page transitions before, between, or after columns/rows; and scoped defaults can set heading, list, panel, horizontal rule, image, drawing, row, paragraph, and table styles through `PdfPageCompose.DefaultHeadingStyle(...)`, `PdfPageCompose.DefaultListStyle(...)`, `PdfPageCompose.DefaultPanelStyle(...)`, `PdfPageCompose.DefaultHorizontalRuleStyle(...)`, `PdfPageCompose.DefaultImageStyle(...)`, `PdfPageCompose.DefaultDrawingStyle(...)`, `PdfPageCompose.DefaultRowStyle(...)`, `PdfPageCompose.DefaultParagraphStyle(...)`, and `PdfPageCompose.DefaultTableStyle(...)`. -- Optional PDF outline generation from H1/H2/H3 headings plus generic `Bookmark(...)` anchors and `LinkToBookmark(...)` text links that emit simple PDF named destinations and GoTo annotations. -- Initial generated AcroForm text fields, check boxes, scalar choice fields, and multi-select choice fields through `PdfDoc.TextField(...)`, `PdfDoc.CheckBox(...)`, `PdfDoc.ChoiceField(...)`, and `PdfDoc.MultiSelectChoiceField(...)`, with top-level, compose item/element, and row/column flow placement, simple visible normal appearances, catalog `/AcroForm` registration, and immediate compatibility with `PdfInspector`, `PdfFormFiller.FillFields(...)`, and `FillAndFlattenFields(...)`. +- Page/section-scoped flow can be created through `PdfDoc.Page(...)`, `PdfDoc.Section(...)`, `PdfDoc.Compose(...Page...)`, or `PdfDoc.Compose(...Section...)`; page content can add direct `Item(...)` groups, nested element groups, `Spacer(...)` rhythm blocks, and `PageBreak()` page transitions before, between, or after columns/rows; and scoped defaults can set page background color plus heading, list, panel, horizontal rule, image, drawing, row, paragraph, and table styles through `PdfPageCompose.Background(...)`, `PdfPageCompose.DefaultHeadingStyle(...)`, `PdfPageCompose.DefaultListStyle(...)`, `PdfPageCompose.DefaultPanelStyle(...)`, `PdfPageCompose.DefaultHorizontalRuleStyle(...)`, `PdfPageCompose.DefaultImageStyle(...)`, `PdfPageCompose.DefaultDrawingStyle(...)`, `PdfPageCompose.DefaultRowStyle(...)`, `PdfPageCompose.DefaultParagraphStyle(...)`, and `PdfPageCompose.DefaultTableStyle(...)`. +- Optional PDF outline generation from H1/H2/H3 headings plus generic `Bookmark(...)` anchors, `LinkToBookmark(...)` text links, and bookmark-targeted heading links that emit simple PDF named destinations and GoTo annotations. +- Initial generated AcroForm text fields, check boxes, scalar choice fields, multi-select choice fields, and vertical radio button groups through `PdfDoc.TextField(...)`, `PdfDoc.CheckBox(...)`, `PdfDoc.ChoiceField(...)`, `PdfDoc.MultiSelectChoiceField(...)`, and `PdfDoc.RadioButtonGroup(...)`, plus table-cell check boxes through `PdfTableCell.WithCheckBoxes(...)` and table-cell text/scalar choice fields through `PdfTableCell.WithFormFields(...)`, with top-level, compose item/element, row/column, and table-cell flow placement, simple visible normal appearances, optional `PdfFormFieldStyle` background/border/text/mark colors, catalog `/AcroForm` registration with `/NeedAppearances false`, and immediate compatibility with `PdfInspector`, `PdfFormFiller.FillFields(...)`, and `FillAndFlattenFields(...)`. - First-party color interop with `OfficeIMO.Drawing.OfficeColor`. - PDF RGB colors reject non-finite or out-of-range components before they can be written as invalid PDF color operators. - `ToBytes`, path and stream `Save`, and path and stream `SaveAsync`. @@ -85,11 +89,12 @@ Reading: - Load from bytes, path, or stream into `PdfReadDocument`. - Enumerate pages, metadata, and document outlines/bookmarks. - Probe PDF header version, encryption markers, digital signature markers, form-field markers, annotation markers, outline/bookmark markers, catalog view-setting markers, page-label markers, catalog name-tree markers, named-destination markers, open-action markers, viewer-preference markers, tagged-structure markers, XMP metadata markers, catalog URI markers, output-intent markers, embedded-file markers, optional-content/layer markers, and active-content markers without full parsing through `PdfInspector.Probe`. -- Preflight PDFs through `PdfInspector.Preflight` to get wrapper-friendly `CanRead`, `CanExtractText`, `CanExtractImages`, `CanReadLogicalObjects`, `CanRewrite`, `CanManipulatePages`, `CanFillSimpleFormFields`, `CanFlattenSimpleFormFields`, `CanFillAndFlattenSimpleFormFields`, `Can(PdfPreflightCapability)`, `GetCapabilityDiagnostics(PdfPreflightCapability)`, parsed `DocumentInfo`, structured `ReadBlockers` and `RewriteBlockers`, `HasReadBlocker(...)` / `HasRewriteBlocker(...)` helpers, and diagnostics before invoking read or manipulation commands; unsupported page content stream filters are reported as read blockers so wrappers can explain why text extraction and logical object readback are not available for a real-world PDF, image extraction can still be allowed when document inspection succeeded, and simple AcroForm fill/flatten gates are exposed separately from generic page-rewrite blockers. +- Validate or preflight PDFs through `PdfValidator.Validate` / `PdfInspector.Preflight` to get wrapper-friendly `IsValid`, `CanRead`, `CanExtractText`, `CanExtractImages`, `CanReadLogicalObjects`, `CanRewrite`, `CanManipulatePages`, `CanFillSimpleFormFields`, `CanFlattenSimpleFormFields`, `CanFillAndFlattenSimpleFormFields`, `Can(PdfPreflightCapability)`, `GetCapabilityDiagnostics(PdfPreflightCapability)`, parsed `DocumentInfo`, structured `ReadBlockers` and `RewriteBlockers`, `HasReadBlocker(...)` / `HasRewriteBlocker(...)` helpers, and diagnostics before invoking read or manipulation commands; unsupported page content stream filters are reported as read blockers so wrappers can explain why text extraction and logical object readback are not available for a real-world PDF, image extraction can still be allowed when document inspection succeeded, and simple AcroForm fill/flatten gates are exposed separately from generic page-rewrite blockers. - Inspect page count, selected source page ranges, page sizes, orientation, inherited page rotation, catalog page mode/layout/version/language values, simple page-label rules, simple document open-action targets, simple viewer preference entries, simple AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, field names/types/kinds/common flags/text max lengths/default appearance strings/text alignment/choice options/scalar or array values/selected options/field page numbers/field-local widget page lookups/widget field names, geometry, and named annotation flags, form field name/kind/page-number lookup helpers, document-level and page-level form widget list/name/page-number lookup helpers, simple page URI and named-destination link annotation summaries, distinct document-level link URI and internal destination targets, document-level page-aware link lists, named destination names/targets, and per-page link annotations with contents metadata through `PdfInspector`; `InspectPageRanges(...)` preserves caller range order and overlaps while narrowing page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets to selected source pages. - Extract document text, selected page-range text, and page-by-page text from bytes, paths, or streams; helpers can write one UTF-8 text result, or per-page text files, for wrapper pipelines. +- Extract logical Markdown from bytes, paths, or streams through `PdfTextExtractor.ExtractMarkdown(...)`, `ExtractMarkdownByPage(...)`, `ExtractMarkdownByPageRanges(...)`, and `ExtractMarkdownByPageRangesAsDocument(...)`, including UTF-8 `.md` path/stream output helpers and deterministic per-page Markdown files for wrapper pipelines while reusing the `PdfLogicalDocument` model. - Extract text spans with positions. -- Build an initial logical read model through `PdfLogicalDocument.Load(...)` / `From(...)`, exposing logical pages, source-page lookup helpers through `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)`, document-level typed collections for text blocks, headings, paragraphs, list items, tables, and images, generic `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on documents and pages, URI/named-destination link annotation objects with document-level URI/destination lookup, page-level AcroForm widget objects, metadata, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, and simple AcroForm fields with typed field-kind/common-flag helpers, text max length, inherited AcroForm/field-tree default appearance strings, text alignment, choice options, scalar or array values, selected options, distinct field page numbers, field-local widget page lookups, named/kind/page lookup, and document/page-level widget lookup helpers by field name or page number so wrappers can start from one stable object surface instead of stitching together low-level extraction helpers; `LoadPageRanges(...)` and `FromPageRanges(...)` return logical objects for selected source page ranges while preserving caller order and overlaps, and range-based logical loads now expose only page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets represented on selected source pages. `PdfLogicalDocument.ToMarkdown(...)` and `PdfLogicalPage.ToMarkdown(...)` render that same logical model as wrapper-friendly Markdown for headings, paragraphs, lists, detected tables, images, and optional link/form annotations without adding a second structure model. +- Build an initial logical read model through `PdfLogicalDocument.Load(...)` / `From(...)`, exposing logical pages, source-page lookup helpers through `PagesBySourcePageNumber`, `HasSourcePage(...)`, and `GetPages(...)`, document-level typed collections for text blocks, headings, paragraphs, list items, tables, and images, generic `Elements`, `ElementsByKind`, `ElementsByPageNumber`, `HasElementKind(...)`, and `GetElements(...)` helpers on documents and pages, URI/named-destination link annotation objects with document-level URI/destination lookup, page-level AcroForm widget objects, metadata, catalog view settings, outlines/bookmarks, page-label rules, named destinations, open actions, viewer preferences, AcroForm `/NeedAppearances`, `/SigFlags` with named signatures-exist/append-only helpers, and `/DA`, and simple AcroForm fields with typed field-kind/common-flag helpers, text max length, inherited AcroForm/field-tree default appearance strings, text alignment, choice options, scalar or array values, selected options, distinct field page numbers, field-local widget page lookups, named/kind/page lookup, and document/page-level widget lookup helpers by field name or page number so wrappers can start from one stable object surface instead of stitching together low-level extraction helpers; `LoadPageRanges(...)` and `FromPageRanges(...)` return logical objects for selected source page ranges while preserving caller order and overlaps, and range-based logical loads now expose only page labels, page-resolved outlines, named destinations, open actions, AcroForm fields, and form widgets represented on selected source pages. `PdfLogicalDocument.ToMarkdown(...)`, `PdfLogicalPage.ToMarkdown(...)`, and the `PdfTextExtractor.ExtractMarkdown...` facade render that same logical model as wrapper-friendly Markdown for headings, paragraphs, lists, detected tables, images, and optional link/form annotations without adding a second structure model. - Extract page image XObjects from bytes, paths, streams, or parsed documents with `PdfImageExtractor`; `ExtractImagesByPageRanges(..., PdfPageRange...)` selects reusable page-range lists for wrapper pipelines, JPEG images are returned as JPEG files and simple PNG-predictor Flate images as PNG files, compatible grayscale/RGB Flate images with grayscale `/SMask` alpha are returned as gray-alpha/RGBA PNGs, and helpers can write extracted images to deterministic page-numbered files. - Heuristic column-aware text extraction and simple structured extraction; `PdfTextExtractor` exposes layout-option overloads for bytes, paths, streams, page-range text/structured/heading/list-item/paragraph/table extraction with `PdfPageRange` lists, byte/path/stream whole-document text output to UTF-8 paths or caller-owned streams, and page-file output, plus structured-by-page, heading-by-page, list-item-by-page, paragraph-by-page, and table-by-page extraction that preserves detected lines, heuristic headings, heuristic paragraph groups, list item marker/level hints, dot/hyphen/underscore leader rows with decimal/currency value punctuation, simple table geometry, and selected source page numbers so wrappers can request readback without dropping to `PdfReadDocument`. Byte-, path-, and stream-based text/table extraction can also write deterministic `source-page-0001.txt` and `source-page-0001-table-0001.csv` files for all pages or selected page ranges, including option-aware selected text page output and the two-page line-item statement fixture's selected table output, with CSV escaping for table output. - Decode common simple streams used by many PDFs, including uncompressed, Flate, ASCIIHex, ASCII85, RunLength, and LZW paths. @@ -103,7 +108,7 @@ Manipulation: - Duplicate selected pages or inclusive page ranges/range lists, move selected pages or inclusive page ranges/range lists, delete selected pages or inclusive page ranges/range lists, reorder all pages from explicit page numbers or `PdfPageRange` lists, and rotate selected/all pages or inclusive page ranges/range lists from bytes, paths, or streams with `PdfPageEditor`, including byte-returning path helpers and output stream helpers for byte, stream, and path inputs. - Update or replace document metadata from bytes, paths, or streams with `PdfMetadataEditor`, including byte-returning path helpers and output stream helpers for byte, stream, and path inputs. - Add simple text/image stamps and text/image watermarks from bytes, paths, or streams with `PdfStamper`, including byte-returning path helpers plus output stream helpers for byte, stream, and path PDF inputs. -- Fill simple AcroForm field values from bytes, paths, or streams with `PdfFormFiller.FillFields(...)`, using fully qualified field names and byte-returning/path/output-stream helpers; current support updates text/string-style values, choice values supplied as export values or `/Opt` display text when available, multi-select choice arrays through `PdfFormFieldValue.FromValues(...)`, and button name values, stores choice export values, generates simple text/choice-widget normal appearance streams plus simple button-widget Off/selected appearance states for widgets with `/Rect`, marks `/NeedAppearances true`, and rejects signed or active-content PDFs. +- Fill simple AcroForm field values from bytes, paths, or streams with `PdfFormFiller.FillFields(...)`, using fully qualified field names and byte-returning/path/output-stream helpers; current support updates text/string-style values, choice values supplied as export values or `/Opt` display text when available, multi-select choice arrays through `PdfFormFieldValue.FromValues(...)`, and button name values, stores choice export values, switches only the matching radio child widget appearance state on, generates simple text/choice-widget normal appearance streams plus simple button-widget Off/selected appearance states for widgets with `/Rect`, marks `/NeedAppearances true`, and rejects signed or active-content PDFs. - Flatten simple text-widget, choice-widget, and button-widget AcroForms from bytes, paths, or streams with `PdfFormFiller.FlattenFields(...)`, or update and flatten in one pass with `FillAndFlattenFields(...)`, including byte-returning/path/output-stream helpers; current support paints text appearances, paints choice option display text from `/Opt` when available for scalar or array selected values, paints simple button-widget normal appearance states into page content, generates minimal button appearances when needed, removes those widget annotations, removes the AcroForm tree, and rejects signed or active-content PDFs. - Rewrite-style manipulation preserves simple direct catalog `/PageMode`, `/PageLayout`, `/Version`, `/Lang`, simple direct `/PageLabels` number trees, simple outline trees including simple GoTo action outline entries whose destinations point only at copied pages, direct `/Dests` dictionaries, simple `/Names` `/Dests` name trees, destination-array and simple GoTo dictionary `/OpenAction` entries, simple `/ViewerPreferences` dictionaries, simple catalog `/Metadata` XMP XML streams, simple catalog `/URI` base dictionaries, simple `/OutputIntents` metadata graphs, simple `/Names` `/EmbeddedFiles` attachment trees, simple catalog `/AF` associated-file arrays, and simple `/OCProperties` optional-content metadata, while pruning stale internal bookmark links whose named destinations no longer survive the selected pages. Copied-page label reindexing follows the trailer-root page tree, not stale catalog objects left behind by earlier revisions. - The current manipulation path copies reachable page object graphs and preserves simple image streams, selected-page URI link annotations, and internal named-destination link annotations with contents metadata across extraction, split, duplicate, move, delete, reorder, rotate, metadata rewrite, merge, and stamp flows when their targets remain reachable, but it is not yet a full arbitrary-PDF editing engine. @@ -114,9 +119,9 @@ Quality Gates The package now has tests that protect the dependency-free promise and start guarding visual quality: - `PackageDependencyGuardrailTests.DependencyLightProjects_HaveNoPackageReferences` fails if `OfficeIMO.Pdf` gains a runtime `PackageReference`. -- `PdfDocVisualQualityTests` checks natural proportional-font word spacing, proportional-font alignment for simple text blocks and headers/footers, mixed Word-like flow rhythm across headings, paragraphs, invisible spacers, panels, lists, tables, images, shapes, and row columns, no-cramped-baseline, same-baseline text-collision, and ambiguous-run-gap guards, row/column text-frame bounds with explicit gutter clearance and baseline rhythm, generic line-item table rhythm and the two-page line-item statement fixture without template APIs, heading wrapping with proportional wide/narrow glyph metrics in top-level and row/column flows, bullet/numbered-list wrapping with proportional wide/narrow glyph metrics in top-level and row/column flows, table-cell wrapping including proportional wide/narrow glyph metrics in top-level and row/column flows plus long unspaced token breaks, currency/percent/accounting-style numeric alignment in top-level and row/column table flows, header-relative body row striping, fixed/relative/min/max/content-aware table column widths plus table max-width, left-indent, column-span placement, row-span placement, header/footer row-count bounds, table keep-with-next preflight diagnostics for invalid table role/span models and column-scoped style bounds including horizontal alignment, rectangular merged-cell fill/border/link/alignment geometry, explicit cell fill/border coordinate bounds, row-spanned separator gaps, row-spanned and rectangular merged-cell default border gaps, row-spanned and column-spanned background-fill gaps, ignored explicit fill/border row-span and column-span continuation-slot coordinates, and linked merged-cell annotation rectangles in top-level and row/column table flows, table-cell link annotation output in top-level and row/column table flows, table keep-together and row-break page-flow behavior, and long-table pagination using rendered PDF text positions. +- `PdfDocVisualQualityTests` checks natural proportional-font word spacing, proportional-font alignment for simple text blocks and headers/footers, mixed Word-like flow rhythm across headings, paragraphs, invisible spacers, panels, lists, tables, images, shapes, and row columns, no-cramped-baseline, same-baseline text-collision, and ambiguous-run-gap guards, row/column text-frame bounds with explicit gutter clearance and baseline rhythm, generic line-item table rhythm and the two-page line-item statement fixture without template APIs, heading wrapping with proportional wide/narrow glyph metrics in top-level and row/column flows, bullet/numbered-list wrapping with proportional wide/narrow glyph metrics in top-level and row/column flows, table-cell wrapping including proportional wide/narrow glyph metrics in top-level and row/column flows plus long unspaced token breaks, currency/percent/accounting-style numeric alignment plus explicit per-cell horizontal/vertical alignment in top-level and row/column table flows, header-relative body row striping, fixed/relative/min/max/content-aware table column widths plus table max-width, left-indent, column-span placement, row-span placement, header/footer row-count bounds, table keep-with-next preflight diagnostics for invalid table role/span models and column-scoped style bounds including horizontal alignment, rectangular merged-cell fill/border/link/alignment geometry, explicit cell fill/border/padding/alignment coordinate bounds, row-spanned separator gaps, row-spanned and rectangular merged-cell default border gaps, row-spanned and column-spanned background-fill gaps, ignored explicit fill/border row-span and column-span continuation-slot coordinates, and linked merged-cell annotation rectangles in top-level and row/column table flows, table-cell link annotation output in top-level and row/column table flows, table keep-together and row-break page-flow behavior, and long-table pagination using rendered PDF text positions. - Justified paragraph checks verify that wrapped lines expand inter-word spacing, final lines and explicit line-break lines keep natural spacing, and text remains extractable. -- Standard font handling uses shared validation for document options, compose default text style, stamp options, writer style selection, metric helpers, PDF base-font name conversion, and WinAnsi text encoding so invalid enum values cannot silently fall back to another font, unsupported generated/stamped characters cannot silently render as `?`, and raw control characters cannot be emitted as invisible PDF text bytes; valid oblique and bold-oblique default-font selections preserve their Helvetica, Times, or Courier family, while generated layout, text span readback, and text stamp/watermark placement for Helvetica and Times family text use built-in glyph-width tables, including common WinAnsi punctuation and accented Latin letters, instead of average character widths. +- Standard font handling uses shared validation for document options, compose default text style, rich text runs, stamp options, writer style selection, metric helpers, PDF base-font name conversion, and WinAnsi text encoding so invalid enum values cannot silently fall back to another font, unsupported generated/stamped characters cannot silently render as `?`, and raw control characters cannot be emitted as invisible PDF text bytes; valid oblique and bold-oblique default-font and run-font selections preserve their Helvetica, Times, or Courier family, while generated layout, text span readback, and text stamp/watermark placement for Helvetica and Times family text use built-in glyph-width tables, including common WinAnsi punctuation and accented Latin letters, instead of average character widths. - Page font resources are emitted only for fonts actually used by visible page content, including header/footer fonts only when headers, footers, or page numbers are enabled. - Page setup rejects invalid intrinsic page sizes and margins at fluent assignment time, while page options report clear layout errors for default/header/footer font enum values, default/header/footer font sizes, header/footer alignment, header/footer placement, and impossible content frames. - `PdfDoc.Create(options)` snapshots caller-provided options so later caller mutations cannot change document rendering. @@ -138,8 +143,8 @@ The package now has tests that protect the dependency-free promise and start gua - Manipulation path input APIs reject null, empty, or whitespace input paths before attempting file reads. - Page-by-page and page-range text extraction can validate/create output directories before reading inputs and write deterministic source-page-numbered text files for wrapper-friendly PSWritePDF parity; `ExtractTextByPageRanges(...)` accepts parsed range lists, preserves caller order, and treats overlapping selections as one page set. - Image extraction can read from bytes, paths, or streams, validate/create output directories before reading path inputs, and write byte-, path-, or stream-based extracted image files with deterministic page-numbered names for wrapper-friendly PSWritePDF parity. -- Rich text runs and link annotations report clear errors for null run text, empty link text, non-absolute link URIs, empty link annotation contents, link contents without a link URI, image/shape/drawing link contents without a URI, and invalid table link coordinates before rendering. -- Paragraph, heading, image, shape, drawing-scene, vector convenience, and table-cell URI link annotations are emitted through a shared annotation dictionary builder, and paragraph `LinkToBookmark(...)` runs emit internal GoTo named-destination link annotations. Generated-PDF output checks verify `/Annots`, `/Subtype /Link`, `/URI` and `/GoTo` actions, escaped `/Contents` metadata, positive in-page link rectangles, aligned heading-link geometry, image placement geometry, fixed visual object geometry, missing bookmark-link diagnostics, and inspector readback, including wrapped heading lines, row/column headings, images, shapes, drawing scenes, vector helper calls, table cells, and bookmark links generated from compose and row/column flows. +- Rich text runs support scoped per-run font-size and background/highlight changes plus link annotations, and report clear errors for null run text, invalid run font sizes, empty link text, non-absolute link URIs, empty link annotation contents, link contents without a link URI, image/shape/drawing link contents without a URI, and invalid table link coordinates before rendering. +- Paragraph, heading, image, shape, drawing-scene, vector convenience, and table-cell URI link annotations are emitted through a shared annotation dictionary builder, and paragraph `LinkToBookmark(...)` runs plus bookmark-targeted headings and table-cell named-destination links emit internal GoTo named-destination link annotations. Table cells can also define reusable named-destination anchors. Generated-PDF output checks verify `/Annots`, `/Subtype /Link`, `/URI` and `/GoTo` actions, escaped `/Contents` metadata, positive in-page link rectangles, aligned heading-link geometry, image placement geometry, fixed visual object geometry, missing bookmark-link diagnostics, and inspector readback, including wrapped heading lines, row/column headings, images, shapes, drawing scenes, vector helper calls, table cells, and bookmark links generated from compose and row/column flows. - Heading-based PDF outlines are emitted through a shared outline dictionary builder and protected by generated-PDF output checks for `/Outlines`, title entries, nested tree links, counts, and `/Dest` destinations, plus inspector readback. Generic `Bookmark(...)` anchors emit sorted simple `/Names` `/Dests` named destinations, reject duplicate names before output, validate internal link targets, and are covered by generated-PDF and inspector readback checks for top-level and row/column flows. - Lightweight probe/readback reports PDF header version, trailer-root catalog page mode/layout/version/language values, simple page-label rules, simple document outline targets including named destinations, simple document open-action targets, simple viewer preference entries, encryption markers, digital signature markers, form-field markers, annotation markers, simple page URI and named-destination link annotation counts, distinct document-level link URI and internal destination targets, document-level page-aware link lists, named destination names/targets, and per-page annotations with contents metadata, outline/bookmark markers, catalog view-setting markers, page-label markers, catalog name-tree markers, named-destination markers, open-action markers, viewer-preference markers, tagged-structure markers, XMP metadata markers, catalog URI markers, output-intent markers, embedded-file markers, optional-content/layer markers, active-content markers, structured preflight read and rewrite blockers, and read/rewrite diagnostics so wrappers can warn before invoking read or manipulation helpers; simple catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, supported catalog name trees, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array and simple GoTo dictionary open actions, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, simple catalog associated-file arrays, and simple optional-content metadata are detected without blocking rewrite. Column-aware text readback now splits wide same-baseline runs before gutter detection so generated row/column documents can be extracted in left-column then right-column order, and structured readback keeps clear single-line table gaps so generated simple tables can round-trip into detected table rows. - Generated metadata is protected by literal-string escaping checks for title, author, subject, and keywords, plus inspector readback of the original values. @@ -149,13 +154,13 @@ The package now has tests that protect the dependency-free promise and start gua - Paragraph scalar style properties reject invalid line height, spacing, and individual indents on assignment while combined text-frame width remains guarded during layout; paragraph style snapshots preserve line height, indents, first-line/hanging indents, spacing, keep-together, keep-with-next, and widow/orphan page-flow settings after the caller mutates the original style. - Paragraph first-line and hanging indents affect both rich-text wrapping and rendered positions in top-level and row/column flows, with diagnostics when the first-line frame would leave the content area or collapse to a non-positive width. - Mutable header, footer, panel-box, and table-caption alignment properties reject unsupported values on assignment instead of carrying invalid style state into rendering. -- Table column horizontal/vertical alignment lists reject unsupported values on assignment, reject out-of-grid entries during table layout/preflight, snapshot the assigned collection so later caller mutations cannot change the style, and are honored in both top-level and row/column table flows. +- Table column horizontal/vertical alignment lists and per-cell horizontal/vertical alignment dictionaries reject unsupported values on assignment, reject out-of-grid entries during table layout/preflight, snapshot the assigned collection so later caller mutations cannot change the style, and are honored in both top-level and row/column table flows. - Table captions render above the grid with configured alignment, color, font size, and spacing in both top-level and row/column table flows. -- Body column fills, per-cell fills, and per-cell borders render in both top-level and row/column table flows. +- Body column fills, per-cell fills, per-cell borders, per-cell padding overrides, and per-cell alignment overrides render in both top-level and row/column table flows. - Header, body, and footer row separators render as line strokes in both top-level and row/column table flows. - Body row striping is calculated relative to the first body row and does not apply to configured header rows in both top-level and row/column table flows. -- Table column sizing lists reject non-positive/non-finite widths or weights on assignment and snapshot the assigned collection while leaving layout-dependent width conflicts to render-time diagnostics. -- Table body-column fills, cell fills, and cell borders snapshot assigned collections; cell fill/border coordinates are validated on assignment, and `PdfCellBorder.Width` rejects invalid intrinsic widths on assignment. +- Table column sizing lists reject non-positive/non-finite widths or weights on assignment and snapshot the assigned collection; oversized fixed column widths are proportionally fit into the available table frame, including row/column flows, while impossible minimum-width conflicts remain render-time diagnostics. +- Table body-column fills, cell fills, cell data bars, cell icons, cell borders, cell padding overrides, and cell alignment overrides snapshot assigned collections; cell fill/data-bar/icon/border/padding/alignment coordinates are validated on assignment, `PdfCellDataBar.Ratio` rejects values outside the 0..1 range, `PdfCellIcon.Size` rejects invalid intrinsic sizes, `PdfCellBorder.Width` plus side-specific `PdfCellBorderSide.Width` reject invalid intrinsic widths, and `PdfCellPadding` rejects invalid intrinsic padding values on assignment. - Heading blocks reject empty or whitespace titles before layout so outlines and visible document structure cannot contain invisible headings. Bookmark blocks reject empty or whitespace names immediately and duplicate names during output so generated named destinations stay deterministic. - Heading blocks reject unsupported alignment values before layout so `Justify` or invalid enum state cannot silently render as left-aligned headings. - Heading style tests cover snapshotting, theme propagation, page-scoped defaults, rendered font size/color, spacing-before/after rhythm, and fresh page/column spacing-before suppression so H1/H2/H3 can move toward Word-like style control instead of hardcoded renderer constants. @@ -170,7 +175,7 @@ The package now has tests that protect the dependency-free promise and start gua - Horizontal rule style tests cover snapshotting, theme propagation, document and page-scoped defaults, rendered stroke color/thickness, spacing rhythm including fresh page/column spacing-before suppression, and keep-with-next page flow for top-level and row/column rules. - Image style tests cover snapshotting, theme propagation, document and page-scoped defaults, rendered alignment/fit coordinates, and spacing rhythm including fresh page/column spacing-before suppression for top-level and row/column images. - Drawing style tests cover snapshotting, theme propagation, document and page-scoped defaults, rendered shape and drawing-scene coordinates, and spacing rhythm including fresh page/column spacing-before suppression for top-level and row/column vector objects. -- Row style tests cover snapshotting, theme propagation, document and page-scoped defaults, rendered gutter coordinates, row-level spacing rhythm including page-top spacing-before suppression, keep-together and keep-with-next page flow, and over-tall row diagnostics for reusable row/column primitives. +- Row style tests cover snapshotting, theme propagation, document and page-scoped defaults, rendered gutter coordinates, optional column separators, row-level spacing rhythm including page-top spacing-before suppression, keep-together and keep-with-next page flow, and over-tall row diagnostics for reusable row/column primitives. - Paragraph keep-together layout moves a whole paragraph to the next page in top-level and row/column flows when it would otherwise split, and reports a clear error when the kept paragraph is taller than the available page content height. - Paragraph keep-with-next layout moves a paragraph with the following visible paragraph/list/panel/table/rule/image/shape/drawing/row-section neighbor in top-level and row/column flows when the first paragraph would otherwise be stranded at the bottom of a page. - Paragraph widow/orphan layout can avoid leaving a single paragraph line at the bottom of a page in top-level and row/column flows. @@ -179,12 +184,12 @@ The package now has tests that protect the dependency-free promise and start gua - Row/column visual-quality checks render ordinary Word-like column primitives, then verify extracted text lines remain inside their column frames, preserve explicit/default gutter clearance, maintain readable baseline rhythm and row-level breathing room, and suppress first flow-object spacing-before at column starts so composition regressions fail before they become cramped reports. - Generic business-shaped visual fixtures, such as line-item tables, stay as proof documents for reusable Word-like primitives: weighted/min-width table columns, wrapped text, right-aligned numeric values, footer/summary row separation, margins, and follow-on rhythm are verified without adding invoice/report concepts to the engine. - Word-like table presets and `PdfTheme.WordLike()` now include neutral footer separator defaults so summary/footer rows have document-style structure without requiring invoice/report-specific style APIs. -- Table scalar style properties reject invalid border widths, row/header/footer separator widths, padding, max width, left indent, row counts, row height, spacing, caption font size, header/body/footer font sizes, line height, and row baseline offsets on assignment while layout-dependent conflicts remain render-time diagnostics. -- Table styles report clear layout errors for invalid captions, unsupported caption justification, alignment enum values, cell fills/borders, explicit cell style coordinates outside the table grid, column-scoped style entries outside the table grid, oversized header/footer row counts, and impossible column sizing. +- Table scalar style properties reject invalid border widths, row/header/footer separator widths, table-wide padding, per-cell padding values, max width, left indent, row counts, table-wide and per-row minimum heights, spacing, caption font size, header/body/footer font sizes, line height, and row baseline offsets on assignment while layout-dependent conflicts remain render-time diagnostics. +- Table styles report clear layout errors for invalid captions, unsupported caption justification, alignment enum values, cell fills/data bars/icons/borders/padding/alignment overrides, explicit cell style coordinates outside the table grid, row-scoped style entries outside the table grid, column-scoped style entries outside the table grid, oversized header/footer row counts, and impossible column sizing. - Table header rows stay visually distinct from body row striping even when a style disables explicit header fill. - Tables can move as a unit when `PdfTableStyle.KeepTogether` is enabled, including row/column flows, and report a clear layout error when the kept table is taller than the available page content height. - Tables can keep with the first visible part of the following block when `PdfTableStyle.KeepWithNext` is enabled and the pair fits inside the page content frame. -- Oversized table rows split across pages by wrapped text line when `PdfTableStyle.AllowRowBreakAcrossPages` is enabled, including row/column flows, and report a clear layout error when row splitting is disabled. +- Oversized table rows split across pages by wrapped text line when `PdfTableStyle.AllowRowBreakAcrossPages` or a per-row `RowAllowBreakAcrossPages` entry allows it, including row/column flows, and report a clear layout error when row splitting is disabled. - Table blocks snapshot input rows, styles, and link dictionaries into read-only model state and normalize null cells before layout so later caller mutations cannot change rendered output. - Shape and drawing blocks snapshot shared `OfficeIMO.Drawing` descriptors, including linear gradient fills, at add time so later caller mutations cannot change rendered output. - Structured and logical PDF readback expose wrapper-friendly documents and pages with typed text blocks, heuristic headings, paragraph groups, list item objects, detected table row/column/cell objects, image XObjects, URI/named-destination link annotation objects with document-level lookup, page-level AcroForm widget objects with current and normal appearance states plus named annotation flags, catalog navigation objects, AcroForm `/NeedAppearances`, `/SigFlags` named helpers, and `/DA`, and simple AcroForm fields/widgets with typed field-kind/common-flag helpers, inherited text max length, inherited AcroForm/field-tree default appearance strings/text alignment, scalar or array current/default values, inherited choice options, selected/default-selected options, plus document-level field/widget lookup by field name, field kind, or page number so PSWriteOffice can start from reusable objects instead of raw text. The two-page line-item statement proof document is covered by logical readback for source page ordering, table rows, totals, and selected page ranges. @@ -198,7 +203,7 @@ The package now has tests that protect the dependency-free promise and start gua - Text/image stamp option models snapshot assigned page-number arrays, provide `UsePageRange(...)` overloads for `firstPage` / `lastPage` pairs or reusable `PdfPageRange` values, plus `UsePageRanges(...)` for parsed range lists without wrappers materializing page arrays; overlapping range-list selections are treated as one page selection set, and invalid intrinsic coordinates, sizes, rotation, fonts, and duplicate/non-positive page selections are rejected before stamping. - Text/image stamp and watermark output is emitted through the shared internal content-stream helper and protected by content-stream checks for placement matrices, color/font operators, image dimensions/rotation, PNG alpha soft masks, and above/below-content layering order; custom image watermark sizing preserves watermark layering. - `PdfDocVisualBaselineTests` keeps representative and professional report geometry snapshots for headings, paragraphs, rich text, panels, bullets, tables, images, PNG alpha soft masks, clipping, axial shading, and vector drawing content-stream signals. -- `PdfDocRasterVisualBaselineTests` can render the professional report, a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, plus compact hello-world, core-layout, style-cheatsheet, styled-runs, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers scenarios through Poppler `pdftoppm`, then compare page PNGs against approved baselines. On mismatch it writes expected, actual, and diff PNG artifacts under `%TEMP%\OfficeIMO.PdfRaster`. Set `OFFICEIMO_REQUIRE_PDF_RASTERIZER=1` to make missing Poppler fail the test lane, `OFFICEIMO_UPDATE_PDF_RASTER_BASELINE=1` to refresh approved PNGs, `OFFICEIMO_PDF_RASTER_PIXEL_TOLERANCE` to allow small per-channel deltas, and `OFFICEIMO_PDF_RASTER_ALLOWED_DIFF_PIXELS` to allow a limited changed-pixel count. +- `PdfDocRasterVisualBaselineTests` can render the professional report, a two-page line-item statement fixture, a Word-like table style gallery with compact Accent1-6 swatches, a landscape showcase dashboard, a native Word-to-first-party-PDF report fixture, a native Word daily-layout fixture covering TOC, margins, columns, separator lines, fonts, colors, lists, links, images, headers/footers, and a table inside the column flow, a native Word table-cell picture-control fixture, a native Excel daily-workbook fixture covering worksheet headers/footers, margins/orientation, merged cells, number formats, explicit row/column sizing, hidden row/column filtering, internal/external hyperlinks, and worksheet/header images, plus compact hello-world, core-layout, style-cheatsheet, styled-runs, drawing-gallery, row-columns, links-rules, lists-tables, default-styles, three-page flow-dsl, and two-page headers-footers scenarios through Poppler `pdftoppm`, then compare page PNGs against approved baselines. On mismatch it writes expected, actual, and diff PNG artifacts under `%TEMP%\OfficeIMO.PdfRaster`. Set `OFFICEIMO_REQUIRE_PDF_RASTERIZER=1` to make missing Poppler fail the test lane, `OFFICEIMO_UPDATE_PDF_RASTER_BASELINE=1` to refresh approved PNGs, `OFFICEIMO_PDF_RASTER_PIXEL_TOLERANCE` to allow small per-channel deltas, and `OFFICEIMO_PDF_RASTER_ALLOWED_DIFF_PIXELS` to allow a limited changed-pixel count. Near-term work should keep adding small visual gates before broad feature growth. The roadmap tracks the intended sequence. diff --git a/OfficeIMO.Pdf/Reading/Model/PdfValidationResult.cs b/OfficeIMO.Pdf/Reading/Model/PdfValidationResult.cs new file mode 100644 index 000000000..c4bc1ea13 --- /dev/null +++ b/OfficeIMO.Pdf/Reading/Model/PdfValidationResult.cs @@ -0,0 +1,76 @@ +namespace OfficeIMO.Pdf; + +/// +/// Wrapper-friendly validation result for checking whether a PDF can be read or safely rewritten by OfficeIMO.Pdf. +/// +public sealed class PdfValidationResult { + internal PdfValidationResult(PdfDocumentPreflight preflight) { + Preflight = preflight; + } + + /// Underlying preflight report with detailed capability and blocker information. + public PdfDocumentPreflight Preflight { get; } + + /// True when OfficeIMO.Pdf can parse enough of the document for read-oriented operations. + public bool IsValid => Preflight.CanRead; + + /// True when OfficeIMO.Pdf can parse enough of the document for read-oriented operations. + public bool CanRead => Preflight.CanRead; + + /// True when OfficeIMO.Pdf can attempt rewrite-style operations without known blockers. + public bool CanRewrite => Preflight.CanRewrite; + + /// True when OfficeIMO.Pdf can extract text from the document. + public bool CanExtractText => Preflight.CanExtractText; + + /// True when OfficeIMO.Pdf can extract images from the document. + public bool CanExtractImages => Preflight.CanExtractImages; + + /// True when OfficeIMO.Pdf can load logical readback objects from the document. + public bool CanReadLogicalObjects => Preflight.CanReadLogicalObjects; + + /// True when OfficeIMO.Pdf can attempt page manipulation helpers without known blockers. + public bool CanManipulatePages => Preflight.CanManipulatePages; + + /// True when OfficeIMO.Pdf can fill supported simple AcroForm fields. + public bool CanFillSimpleFormFields => Preflight.CanFillSimpleFormFields; + + /// True when OfficeIMO.Pdf can flatten supported simple AcroForm fields. + public bool CanFlattenSimpleFormFields => Preflight.CanFlattenSimpleFormFields; + + /// True when OfficeIMO.Pdf can fill and flatten supported simple AcroForm fields. + public bool CanFillAndFlattenSimpleFormFields => Preflight.CanFillAndFlattenSimpleFormFields; + + /// Parsed document information when validation reached inspection. + public PdfDocumentInfo? DocumentInfo => Preflight.DocumentInfo; + + /// Lightweight PDF markers read before full parsing. + public PdfDocumentProbe Probe => Preflight.Probe; + + /// PDF header version when one was discovered. + public string? HeaderVersion => Preflight.Probe.HeaderVersion; + + /// Page count when the document could be inspected; otherwise 0. + public int PageCount => Preflight.DocumentInfo?.PageCount ?? 0; + + /// Human-readable diagnostics explaining validation failures or rewrite blockers. + public IReadOnlyList Diagnostics => Preflight.Diagnostics; + + /// Structured reasons why read-oriented validation failed. + public IReadOnlyList ReadBlockers => Preflight.ReadBlockers; + + /// Structured reasons why rewrite-style operations are blocked. + public IReadOnlyList RewriteBlockers => Preflight.RewriteBlockers; + + /// Returns true when a specific read blocker is present. + public bool HasReadBlocker(PdfReadBlockerKind kind) => Preflight.HasReadBlocker(kind); + + /// Returns true when a specific rewrite blocker is present. + public bool HasRewriteBlocker(PdfRewriteBlockerKind kind) => Preflight.HasRewriteBlocker(kind); + + /// Returns true when a specific read, extraction, or manipulation capability is available. + public bool Can(PdfPreflightCapability capability) => Preflight.Can(capability); + + /// Returns diagnostics explaining why a specific capability is unavailable. + public IReadOnlyList GetCapabilityDiagnostics(PdfPreflightCapability capability) => Preflight.GetCapabilityDiagnostics(capability); +} diff --git a/OfficeIMO.Pdf/Reading/PdfTextExtractor.cs b/OfficeIMO.Pdf/Reading/PdfTextExtractor.cs index e683e6697..d1c2f8406 100644 --- a/OfficeIMO.Pdf/Reading/PdfTextExtractor.cs +++ b/OfficeIMO.Pdf/Reading/PdfTextExtractor.cs @@ -130,6 +130,54 @@ public static void ExtractAllTextByPageRanges(string inputPath, string outputPat WriteTextOutput(fullOutputPath, ExtractAllTextByPageRanges(inputPath, options, pageRanges)); } + /// Extracts logical Markdown from all pages. + public static string ExtractMarkdown(string path, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNullOrWhiteSpace(path, nameof(path)); + return PdfLogicalDocument.Load(path, options).ToMarkdown(markdownOptions); + } + + /// Extracts logical Markdown from all pages and writes UTF-8 Markdown to . + public static void ExtractMarkdown(string inputPath, Stream outputStream, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNullOrWhiteSpace(inputPath, nameof(inputPath)); + ValidateWritableOutputStream(outputStream); + WriteTextOutput(outputStream, ExtractMarkdown(inputPath, options, markdownOptions)); + } + + /// Extracts logical Markdown from all pages and writes UTF-8 Markdown to . + public static void ExtractMarkdown(string inputPath, string outputPath, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNullOrWhiteSpace(inputPath, nameof(inputPath)); + string fullOutputPath = ValidateOutputPath(outputPath); + WriteTextOutput(fullOutputPath, ExtractMarkdown(inputPath, options, markdownOptions)); + } + + /// Extracts logical Markdown from each page in document order. + public static IReadOnlyList ExtractMarkdownByPage(string path, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNullOrWhiteSpace(path, nameof(path)); + return ExtractMarkdownByPage(PdfLogicalDocument.Load(path, options), markdownOptions); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges in caller order. + public static IReadOnlyList ExtractMarkdownByPageRanges(string path, params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRanges(path, null, null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges in caller order. + public static IReadOnlyList ExtractMarkdownByPageRanges(string path, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + Guard.NotNullOrWhiteSpace(path, nameof(path)); + return ExtractMarkdownByPage(PdfLogicalDocument.LoadPageRanges(path, options, pageRanges), markdownOptions); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges and concatenates selected pages with Markdown page separators. + public static string ExtractMarkdownByPageRangesAsDocument(string path, params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRangesAsDocument(path, null, null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges and concatenates selected pages with Markdown page separators. + public static string ExtractMarkdownByPageRangesAsDocument(string path, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + Guard.NotNullOrWhiteSpace(path, nameof(path)); + return PdfLogicalDocument.LoadPageRanges(path, options, pageRanges).ToMarkdown(markdownOptions); + } + /// Extracts structured content for each page, including detected lines, lists, leader rows, and simple tables. public static IReadOnlyList ExtractStructuredByPage(string path, PdfTextLayoutOptions? options = null) { Guard.NotNullOrWhiteSpace(path, nameof(path)); @@ -338,6 +386,31 @@ public static IReadOnlyList ExtractTextByPage(string inputPath, string o return WriteTextPages(inputPath, fullOutputDirectory, pages); } + /// Extracts logical Markdown from each page and writes one UTF-8 Markdown file per page. + public static IReadOnlyList ExtractMarkdownByPage(string inputPath, string outputDirectory, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNull(inputPath, nameof(inputPath)); + Guard.NotNull(outputDirectory, nameof(outputDirectory)); + + string fullOutputDirectory = ValidateOutputDirectory(outputDirectory); + var pages = ExtractMarkdownByPage(inputPath, options, markdownOptions); + return WriteMarkdownPages(inputPath, fullOutputDirectory, pages); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges and writes one UTF-8 Markdown file per selected source page. + public static IReadOnlyList ExtractMarkdownByPageRanges(string inputPath, string outputDirectory, params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRanges(inputPath, outputDirectory, null, null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges and writes one UTF-8 Markdown file per selected source page. + public static IReadOnlyList ExtractMarkdownByPageRanges(string inputPath, string outputDirectory, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + Guard.NotNull(inputPath, nameof(inputPath)); + Guard.NotNull(outputDirectory, nameof(outputDirectory)); + + string fullOutputDirectory = ValidateOutputDirectory(outputDirectory); + var pages = ExtractSelectedMarkdownPages(PdfLogicalDocument.LoadPageRanges(inputPath, options, pageRanges), markdownOptions); + return WriteMarkdownPages(inputPath, fullOutputDirectory, pages); + } + private static List WriteTextPages(string baseName, string fullOutputDirectory, IReadOnlyList pages) { string safeBaseName = GetSafeBaseName(baseName, "page"); @@ -351,6 +424,39 @@ private static List WriteTextPages(string baseName, string fullOutputDir return paths; } + private static List WriteMarkdownPages(string baseName, string fullOutputDirectory, IReadOnlyList pages) { + string safeBaseName = GetSafeBaseName(baseName, "page"); + + var paths = new List(pages.Count); + for (int i = 0; i < pages.Count; i++) { + string outputPath = Path.Combine(fullOutputDirectory, safeBaseName + "-page-" + (i + 1).ToString("0000", System.Globalization.CultureInfo.InvariantCulture) + ".md"); + File.WriteAllText(outputPath, pages[i], new UTF8Encoding(false)); + paths.Add(outputPath); + } + + return paths; + } + + private static List WriteMarkdownPages(string baseName, string fullOutputDirectory, IReadOnlyList pages) { + string safeBaseName = GetSafeBaseName(baseName, "page"); + + var paths = new List(pages.Count); + var pageOccurrences = new Dictionary(); + for (int i = 0; i < pages.Count; i++) { + int occurrence = IncrementOccurrence(pageOccurrences, pages[i].PageNumber); + string outputPath = Path.Combine( + fullOutputDirectory, + safeBaseName + + "-page-" + pages[i].PageNumber.ToString("0000", System.Globalization.CultureInfo.InvariantCulture) + + BuildOccurrenceSuffix(occurrence) + + ".md"); + File.WriteAllText(outputPath, pages[i].Text, new UTF8Encoding(false)); + paths.Add(outputPath); + } + + return paths; + } + private static List WriteTextPages(string baseName, string fullOutputDirectory, IReadOnlyList pages) { string safeBaseName = GetSafeBaseName(baseName, "page"); @@ -598,6 +704,48 @@ public static void ExtractAllTextByPageRanges(Stream inputStream, string outputP WriteTextOutput(fullOutputPath, ExtractAllTextByPageRanges(inputStream, options, pageRanges)); } + /// Extracts logical Markdown from all pages from the current position of a readable stream. + public static string ExtractMarkdown(Stream stream, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + return PdfLogicalDocument.Load(stream, options).ToMarkdown(markdownOptions); + } + + /// Extracts logical Markdown from all pages from the current position of a readable stream and writes UTF-8 Markdown to . + public static void ExtractMarkdown(Stream inputStream, Stream outputStream, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + ValidateWritableOutputStream(outputStream); + WriteTextOutput(outputStream, ExtractMarkdown(inputStream, options, markdownOptions)); + } + + /// Extracts logical Markdown from all pages from the current position of a readable stream and writes UTF-8 Markdown to . + public static void ExtractMarkdown(Stream inputStream, string outputPath, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + string fullOutputPath = ValidateOutputPath(outputPath); + WriteTextOutput(fullOutputPath, ExtractMarkdown(inputStream, options, markdownOptions)); + } + + /// Extracts logical Markdown from each page from the current position of a readable stream. + public static IReadOnlyList ExtractMarkdownByPage(Stream stream, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + return ExtractMarkdownByPage(PdfLogicalDocument.Load(stream, options), markdownOptions); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges from the current position of a readable stream. + public static IReadOnlyList ExtractMarkdownByPageRanges(Stream stream, params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRanges(stream, (PdfTextLayoutOptions?)null, (PdfLogicalMarkdownOptions?)null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges from the current position of a readable stream. + public static IReadOnlyList ExtractMarkdownByPageRanges(Stream stream, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPage(PdfLogicalDocument.LoadPageRanges(stream, options, pageRanges), markdownOptions); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges and concatenates selected pages with Markdown page separators. + public static string ExtractMarkdownByPageRangesAsDocument(Stream stream, params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRangesAsDocument(stream, (PdfTextLayoutOptions?)null, (PdfLogicalMarkdownOptions?)null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges and concatenates selected pages with Markdown page separators. + public static string ExtractMarkdownByPageRangesAsDocument(Stream stream, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + return PdfLogicalDocument.LoadPageRanges(stream, options, pageRanges).ToMarkdown(markdownOptions); + } + /// Extracts plain text from each page from the current stream position and writes one UTF-8 text file per page. public static IReadOnlyList ExtractTextByPage(Stream stream, string outputDirectory, string baseName = "page", PdfTextLayoutOptions? options = null) { Guard.NotNull(outputDirectory, nameof(outputDirectory)); @@ -625,6 +773,29 @@ public static IReadOnlyList ExtractTextByPageRanges(Stream stream, strin return WriteTextPages(baseName, fullOutputDirectory, pages); } + /// Extracts logical Markdown from each page from the current stream position and writes one UTF-8 Markdown file per page. + public static IReadOnlyList ExtractMarkdownByPage(Stream stream, string outputDirectory, string baseName = "page", PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNull(outputDirectory, nameof(outputDirectory)); + + string fullOutputDirectory = ValidateOutputDirectory(outputDirectory); + var pages = ExtractMarkdownByPage(stream, options, markdownOptions); + return WriteMarkdownPages(baseName, fullOutputDirectory, pages); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges from the current stream position and writes one UTF-8 Markdown file per selected source page. + public static IReadOnlyList ExtractMarkdownByPageRanges(Stream stream, string outputDirectory, string baseName = "page", params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRanges(stream, outputDirectory, baseName, null, null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges from the current stream position and writes one UTF-8 Markdown file per selected source page. + public static IReadOnlyList ExtractMarkdownByPageRanges(Stream stream, string outputDirectory, string baseName, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + Guard.NotNull(outputDirectory, nameof(outputDirectory)); + + string fullOutputDirectory = ValidateOutputDirectory(outputDirectory); + var pages = ExtractSelectedMarkdownPages(PdfLogicalDocument.LoadPageRanges(stream, options, pageRanges), markdownOptions); + return WriteMarkdownPages(baseName, fullOutputDirectory, pages); + } + /// Extracts plain text from each page from bytes and writes one UTF-8 text file per page. public static IReadOnlyList ExtractTextByPage(byte[] pdf, string outputDirectory, string baseName = "page", PdfTextLayoutOptions? options = null) { Guard.NotNull(pdf, nameof(pdf)); @@ -906,6 +1077,79 @@ public static void ExtractAllTextByPageRanges(byte[] pdf, string outputPath, Pdf WriteTextOutput(fullOutputPath, ExtractAllTextByPageRanges(pdf, options, pageRanges)); } + /// Extracts logical Markdown from all pages. + public static string ExtractMarkdown(byte[] pdf, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNull(pdf, nameof(pdf)); + return PdfLogicalDocument.Load(pdf, options).ToMarkdown(markdownOptions); + } + + /// Extracts logical Markdown from all pages and writes UTF-8 Markdown to . + public static void ExtractMarkdown(byte[] pdf, Stream outputStream, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNull(pdf, nameof(pdf)); + ValidateWritableOutputStream(outputStream); + WriteTextOutput(outputStream, ExtractMarkdown(pdf, options, markdownOptions)); + } + + /// Extracts logical Markdown from all pages and writes UTF-8 Markdown to . + public static void ExtractMarkdown(byte[] pdf, string outputPath, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNull(pdf, nameof(pdf)); + string fullOutputPath = ValidateOutputPath(outputPath); + WriteTextOutput(fullOutputPath, ExtractMarkdown(pdf, options, markdownOptions)); + } + + /// Extracts logical Markdown from each page in document order. + public static IReadOnlyList ExtractMarkdownByPage(byte[] pdf, PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNull(pdf, nameof(pdf)); + return ExtractMarkdownByPage(PdfLogicalDocument.Load(pdf, options), markdownOptions); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges in caller order. + public static IReadOnlyList ExtractMarkdownByPageRanges(byte[] pdf, params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRanges(pdf, (PdfTextLayoutOptions?)null, (PdfLogicalMarkdownOptions?)null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges in caller order. + public static IReadOnlyList ExtractMarkdownByPageRanges(byte[] pdf, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + Guard.NotNull(pdf, nameof(pdf)); + return ExtractMarkdownByPage(PdfLogicalDocument.LoadPageRanges(pdf, options, pageRanges), markdownOptions); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges and concatenates selected pages with Markdown page separators. + public static string ExtractMarkdownByPageRangesAsDocument(byte[] pdf, params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRangesAsDocument(pdf, (PdfTextLayoutOptions?)null, (PdfLogicalMarkdownOptions?)null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges and concatenates selected pages with Markdown page separators. + public static string ExtractMarkdownByPageRangesAsDocument(byte[] pdf, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + Guard.NotNull(pdf, nameof(pdf)); + return PdfLogicalDocument.LoadPageRanges(pdf, options, pageRanges).ToMarkdown(markdownOptions); + } + + /// Extracts logical Markdown from each page from bytes and writes one UTF-8 Markdown file per page. + public static IReadOnlyList ExtractMarkdownByPage(byte[] pdf, string outputDirectory, string baseName = "page", PdfTextLayoutOptions? options = null, PdfLogicalMarkdownOptions? markdownOptions = null) { + Guard.NotNull(pdf, nameof(pdf)); + Guard.NotNull(outputDirectory, nameof(outputDirectory)); + + string fullOutputDirectory = ValidateOutputDirectory(outputDirectory); + var pages = ExtractMarkdownByPage(pdf, options, markdownOptions); + return WriteMarkdownPages(baseName, fullOutputDirectory, pages); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges from bytes and writes one UTF-8 Markdown file per selected source page. + public static IReadOnlyList ExtractMarkdownByPageRanges(byte[] pdf, string outputDirectory, string baseName = "page", params PdfPageRange[] pageRanges) { + return ExtractMarkdownByPageRanges(pdf, outputDirectory, baseName, null, null, pageRanges); + } + + /// Extracts logical Markdown from the supplied inclusive one-based page ranges from bytes and writes one UTF-8 Markdown file per selected source page. + public static IReadOnlyList ExtractMarkdownByPageRanges(byte[] pdf, string outputDirectory, string baseName, PdfTextLayoutOptions? options, PdfLogicalMarkdownOptions? markdownOptions, params PdfPageRange[] pageRanges) { + Guard.NotNull(pdf, nameof(pdf)); + Guard.NotNull(outputDirectory, nameof(outputDirectory)); + + string fullOutputDirectory = ValidateOutputDirectory(outputDirectory); + var pages = ExtractSelectedMarkdownPages(PdfLogicalDocument.LoadPageRanges(pdf, options, pageRanges), markdownOptions); + return WriteMarkdownPages(baseName, fullOutputDirectory, pages); + } + /// Extracts structured content for each page, including detected lines, lists, leader rows, and simple tables. public static IReadOnlyList ExtractStructuredByPage(byte[] pdf, PdfTextLayoutOptions? options = null) { Guard.NotNull(pdf, nameof(pdf)); @@ -1116,6 +1360,25 @@ private static System.Collections.ObjectModel.ReadOnlyCollection ExtractMarkdownByPage(PdfLogicalDocument document, PdfLogicalMarkdownOptions? markdownOptions) { + var pages = new List(document.Pages.Count); + for (int i = 0; i < document.Pages.Count; i++) { + pages.Add(document.Pages[i].ToMarkdown(markdownOptions)); + } + + return pages.AsReadOnly(); + } + + private static System.Collections.ObjectModel.ReadOnlyCollection ExtractSelectedMarkdownPages(PdfLogicalDocument document, PdfLogicalMarkdownOptions? markdownOptions) { + var pages = new List(document.Pages.Count); + for (int i = 0; i < document.Pages.Count; i++) { + PdfLogicalPage page = document.Pages[i]; + pages.Add(new SelectedTextPage(page.PageNumber, page.ToMarkdown(markdownOptions))); + } + + return pages.AsReadOnly(); + } + private readonly struct SelectedTextPage { internal SelectedTextPage(int pageNumber, string text) { PageNumber = pageNumber; diff --git a/OfficeIMO.Pdf/Reading/PdfValidator.cs b/OfficeIMO.Pdf/Reading/PdfValidator.cs new file mode 100644 index 000000000..e1fa43581 --- /dev/null +++ b/OfficeIMO.Pdf/Reading/PdfValidator.cs @@ -0,0 +1,34 @@ +namespace OfficeIMO.Pdf; + +/// +/// Wrapper-friendly PDF validation helpers backed by the OfficeIMO.Pdf preflight engine. +/// +public static class PdfValidator { + /// + /// Validates a PDF from a byte array without throwing for malformed PDF content. + /// + public static PdfValidationResult Validate(byte[] pdf, PdfReadOptions? options = null) { + Guard.NotNull(pdf, nameof(pdf)); + return new PdfValidationResult(PdfInspector.Preflight(pdf, options)); + } + + /// + /// Validates a PDF from a file path without throwing for malformed PDF content. + /// + public static PdfValidationResult Validate(string path, PdfReadOptions? options = null) { + Guard.NotNullOrWhiteSpace(path, nameof(path)); + return Validate(File.ReadAllBytes(path), options); + } + + /// + /// Validates a PDF from the current position of a readable stream without throwing for malformed PDF content. + /// + public static PdfValidationResult Validate(Stream stream, PdfReadOptions? options = null) { + Guard.NotNull(stream, nameof(stream)); + if (!stream.CanRead) throw new ArgumentException("Stream must be readable.", nameof(stream)); + + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return Validate(buffer.ToArray(), options); + } +} diff --git a/OfficeIMO.Pdf/Rendering/PdfWriter.cs b/OfficeIMO.Pdf/Rendering/PdfWriter.cs index 81f2c76de..63a4d9bc5 100644 --- a/OfficeIMO.Pdf/Rendering/PdfWriter.cs +++ b/OfficeIMO.Pdf/Rendering/PdfWriter.cs @@ -71,15 +71,21 @@ string EnsurePageFontResource(PdfStandardFont font, string preferredAlias) { var biFont = ChooseBoldItalic(normalFont); EnsurePageFontResource(biFont, "F4"); } + foreach (PdfStandardFont usedFont in page.UsedFonts) { + EnsurePageFontResource(usedFont, GetStandardFontResourceName(usedFont, normalFont)); + } string? headerFontAlias = null; - if (pageOpts.HasHeaderContentForPage(headerFooterVariantPageNumber)) { + if (pageOpts.HasHeaderTextContentForPage(headerFooterVariantPageNumber)) { headerFontAlias = EnsurePageFontResource(pageOpts.HeaderFont, "F5"); } string? footerFontAlias = null; - if (pageOpts.HasFooterContentForPage(headerFooterVariantPageNumber)) { + if (pageOpts.HasFooterTextContentForPage(headerFooterVariantPageNumber)) { footerFontAlias = EnsurePageFontResource(pageOpts.FooterFont, "F6"); } + string pageBackgroundContent = BuildPageBackground(pageOpts); + string headerFooterShapeContent = BuildHeaderFooterShapes(page, pageOpts, headerFooterVariantPageNumber); + var fontResources = new List<(string Name, int Id)>(); foreach (var kvp in pageFontResources.OrderBy(kvp => kvp.Value, StringComparer.Ordinal)) { fontResources.Add((kvp.Value, EnsureFont(kvp.Key))); @@ -108,11 +114,13 @@ string EnsurePageFontResource(PdfStandardFont font, string preferredAlias) { } // Content stream (append image draw commands at end) - string contentStr = page.Content; - if (pageOpts.HasHeaderContentForPage(headerFooterVariantPageNumber)) { + AddHeaderFooterImages(page, pageOpts, headerFooterVariantPageNumber); + string contentStr = pageBackgroundContent + headerFooterShapeContent; + if (pageOpts.HasHeaderTextContentForPage(headerFooterVariantPageNumber)) { string headerContent = BuildHeader(pageOpts, headerFooterVariantPageNumber, headerFooterPageNumber, headerFooterTotalPages, pageOpts.HeaderFont, headerFontAlias!); - contentStr = headerContent + contentStr; + contentStr += headerContent; } + contentStr += page.Content; var xobjects = new List<(string Name, int Id)>(); if (page.Images.Count > 0) { for (int i = 0; i < page.Images.Count; i++) { @@ -156,7 +164,7 @@ string EnsurePageFontResource(PdfStandardFont font, string preferredAlias) { } contentStr += sbImgs.ToString(); } - if (pageOpts.HasFooterContentForPage(headerFooterVariantPageNumber)) { + if (pageOpts.HasFooterTextContentForPage(headerFooterVariantPageNumber)) { string footer = BuildFooter(pageOpts, headerFooterVariantPageNumber, headerFooterPageNumber, headerFooterTotalPages, pageOpts.FooterFont, footerFontAlias!); contentStr += footer; } @@ -184,31 +192,68 @@ string EnsurePageFontResource(PdfStandardFont font, string preferredAlias) { string formField; double appearanceWidth = field.X2 - field.X1; double appearanceHeight = field.Y2 - field.Y1; + if (field.Kind == FormFieldAnnotationKind.RadioButtonGroup) { + int parentFieldId = ReserveObject(objects); + string offAppearance = PdfAcroFormDictionaryBuilder.BuildRadioButtonAppearanceContent(field.ButtonSize, field.ButtonSize, selected: false, field.Style); + byte[] offAppearanceBytes = PdfEncoding.Latin1GetBytes(offAppearance); + string offAppearanceDictionary = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceStreamDictionary(field.ButtonSize, field.ButtonSize, offAppearanceBytes.Length); + int offAppearanceId = AddStreamObject(objects, offAppearanceDictionary, offAppearanceBytes); + + string selectedAppearance = PdfAcroFormDictionaryBuilder.BuildRadioButtonAppearanceContent(field.ButtonSize, field.ButtonSize, selected: true, field.Style); + byte[] selectedAppearanceBytes = PdfEncoding.Latin1GetBytes(selectedAppearance); + string selectedAppearanceDictionary = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceStreamDictionary(field.ButtonSize, field.ButtonSize, selectedAppearanceBytes.Length); + int selectedAppearanceId = AddStreamObject(objects, selectedAppearanceDictionary, selectedAppearanceBytes); + + var widgetObjectIds = new List(field.Options.Count); + for (int optionIndex = 0; optionIndex < field.Options.Count; optionIndex++) { + double widgetTop = field.Y2 - optionIndex * (field.ButtonSize + field.ButtonGap); + double widgetBottom = widgetTop - field.ButtonSize; + string widget = PdfAnnotationDictionaryBuilder.BuildRadioButtonWidgetAnnotation( + field.X1, + widgetBottom, + field.X1 + field.ButtonSize, + widgetTop, + parentFieldId, + field.Options[optionIndex], + field.Value, + offAppearanceId, + selectedAppearanceId, + field.Style); + int widgetObjectId = AddObject(objects, widget); + widgetObjectIds.Add(widgetObjectId); + pageAnnotIds.Add(widgetObjectId); + } + + ReplaceObject(objects, parentFieldId, PdfAnnotationDictionaryBuilder.BuildRadioButtonFieldDictionary(field.Name, field.Options, field.Value, widgetObjectIds)); + formFieldIds.Add(parentFieldId); + continue; + } + if (field.Kind == FormFieldAnnotationKind.CheckBox) { - string offAppearance = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceContent(appearanceWidth, appearanceHeight, selected: false); + string offAppearance = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceContent(appearanceWidth, appearanceHeight, selected: false, field.Style); byte[] offAppearanceBytes = PdfEncoding.Latin1GetBytes(offAppearance); string offAppearanceDictionary = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceStreamDictionary(appearanceWidth, appearanceHeight, offAppearanceBytes.Length); int offAppearanceId = AddStreamObject(objects, offAppearanceDictionary, offAppearanceBytes); - string checkedAppearance = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceContent(appearanceWidth, appearanceHeight, selected: true); + string checkedAppearance = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceContent(appearanceWidth, appearanceHeight, selected: true, field.Style); byte[] checkedAppearanceBytes = PdfEncoding.Latin1GetBytes(checkedAppearance); string checkedAppearanceDictionary = PdfAcroFormDictionaryBuilder.BuildCheckBoxAppearanceStreamDictionary(appearanceWidth, appearanceHeight, checkedAppearanceBytes.Length); int checkedAppearanceId = AddStreamObject(objects, checkedAppearanceDictionary, checkedAppearanceBytes); - formField = PdfAnnotationDictionaryBuilder.BuildCheckBoxWidgetAnnotation(field.X1, field.Y1, field.X2, field.Y2, field.Name, field.IsChecked, field.CheckedValueName, offAppearanceId, checkedAppearanceId); + formField = PdfAnnotationDictionaryBuilder.BuildCheckBoxWidgetAnnotation(field.X1, field.Y1, field.X2, field.Y2, field.Name, field.IsChecked, field.CheckedValueName, offAppearanceId, checkedAppearanceId, field.Style); } else if (field.Kind == FormFieldAnnotationKind.Choice) { string appearanceValue = field.Values.Count > 1 ? string.Join(", ", field.Values) : field.Value; - string appearanceContent = PdfAcroFormDictionaryBuilder.BuildTextFieldAppearanceContent(appearanceWidth, appearanceHeight, appearanceValue, field.FontSize); + string appearanceContent = PdfAcroFormDictionaryBuilder.BuildTextFieldAppearanceContent(appearanceWidth, appearanceHeight, appearanceValue, field.FontSize, field.Style); byte[] appearanceBytes = PdfEncoding.Latin1GetBytes(appearanceContent); string appearanceDictionary = PdfAcroFormDictionaryBuilder.BuildTextFieldAppearanceStreamDictionary(appearanceWidth, appearanceHeight, helveticaFontId, appearanceBytes.Length); int appearanceId = AddStreamObject(objects, appearanceDictionary, appearanceBytes); - formField = PdfAnnotationDictionaryBuilder.BuildChoiceFieldWidgetAnnotation(field.X1, field.Y1, field.X2, field.Y2, field.Name, field.Options, field.Values.Count == 0 ? new[] { field.Value } : field.Values, field.FontSize, appearanceId, field.IsComboBox, field.AllowsMultipleSelection); + formField = PdfAnnotationDictionaryBuilder.BuildChoiceFieldWidgetAnnotation(field.X1, field.Y1, field.X2, field.Y2, field.Name, field.Options, field.Values.Count == 0 ? new[] { field.Value } : field.Values, field.FontSize, appearanceId, field.IsComboBox, field.AllowsMultipleSelection, field.Style); } else { - string appearanceContent = PdfAcroFormDictionaryBuilder.BuildTextFieldAppearanceContent(appearanceWidth, appearanceHeight, field.Value, field.FontSize); + string appearanceContent = PdfAcroFormDictionaryBuilder.BuildTextFieldAppearanceContent(appearanceWidth, appearanceHeight, field.Value, field.FontSize, field.Style); byte[] appearanceBytes = PdfEncoding.Latin1GetBytes(appearanceContent); string appearanceDictionary = PdfAcroFormDictionaryBuilder.BuildTextFieldAppearanceStreamDictionary(appearanceWidth, appearanceHeight, helveticaFontId, appearanceBytes.Length); int appearanceId = AddStreamObject(objects, appearanceDictionary, appearanceBytes); - formField = PdfAnnotationDictionaryBuilder.BuildTextFieldWidgetAnnotation(field.X1, field.Y1, field.X2, field.Y2, field.Name, field.Value, field.FontSize, appearanceId); + formField = PdfAnnotationDictionaryBuilder.BuildTextFieldWidgetAnnotation(field.X1, field.Y1, field.X2, field.Y2, field.Name, field.Value, field.FontSize, appearanceId, field.Style); } int formFieldId = AddObject(objects, formField); @@ -250,6 +295,21 @@ string EnsurePageFontResource(PdfStandardFont font, string preferredAlias) { return PdfFileAssembler.Assemble(objects, catalogId, infoId); } + private static string BuildPageBackground(PdfOptions options) { + if (!options.BackgroundColor.HasValue) { + return string.Empty; + } + + var sb = new StringBuilder(); + new ContentStreamBuilder(sb) + .SaveState() + .FillColor(options.BackgroundColor.Value) + .Rectangle(0, 0, options.PageWidth, options.PageHeight) + .FillPath() + .RestoreState(); + return sb.ToString(); + } + private static List BuildPageNumberInfos(IReadOnlyList pages) { var seen = new Dictionary(); var pending = new List<(int VariantPageNumber, int PageNumber, int SequenceId)>(pages.Count); diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfAcroFormDictionaryBuilder.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfAcroFormDictionaryBuilder.cs index 5650ecc91..e25f9b81b 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfAcroFormDictionaryBuilder.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfAcroFormDictionaryBuilder.cs @@ -14,7 +14,7 @@ internal static string BuildAcroFormDictionary(IReadOnlyList fieldObjectIds .Append(PdfSyntaxEscaper.IndirectReference(fieldObjectIds[i])); } - sb.Append(" ] /NeedAppearances true /DR << /Font << /Helv ") + sb.Append(" ] /NeedAppearances false /DR << /Font << /Helv ") .Append(PdfSyntaxEscaper.IndirectReference(helveticaFontId)) .Append(" >> >> /DA (/Helv 10 Tf 0 g) >>\n"); return sb.ToString(); @@ -54,12 +54,13 @@ internal static string BuildCheckBoxAppearanceStreamDictionary(double width, dou " >>"; } - internal static string BuildTextFieldAppearanceContent(double width, double height, string value, double fontSize) { + internal static string BuildTextFieldAppearanceContent(double width, double height, string value, double fontSize, PdfFormFieldStyle? style = null) { Guard.Positive(width, nameof(width)); Guard.Positive(height, nameof(height)); Guard.NotNull(value, nameof(value)); Guard.Positive(fontSize, nameof(fontSize)); + PdfFormFieldStyle effectiveStyle = style ?? new PdfFormFieldStyle(); double baseline = Math.Max(2D, (height - fontSize) / 2D + fontSize * 0.72D); double textX = 3D; double textWidth = Math.Max(0D, width - 6D); @@ -68,23 +69,36 @@ internal static string BuildTextFieldAppearanceContent(double width, double heig clippedValue = string.Empty; } - return "q\n" + - "1 1 1 rg 0 0 " + Format(width) + " " + Format(height) + " re f\n" + - "0.75 G 1 w 0.5 0.5 " + Format(Math.Max(0D, width - 1D)) + " " + Format(Math.Max(0D, height - 1D)) + " re S\n" + - "BT /Helv " + Format(fontSize) + " Tf 0 g " + Format(textX) + " " + Format(baseline) + " Td " + PdfSyntaxEscaper.WinAnsiHexString(clippedValue) + " Tj ET\n" + - "Q\n"; + string content = "q\n"; + if (effectiveStyle.BackgroundColor.HasValue) { + content += FormatColor(effectiveStyle.BackgroundColor.Value) + " rg 0 0 " + Format(width) + " " + Format(height) + " re f\n"; + } + + if (effectiveStyle.BorderColor.HasValue && effectiveStyle.BorderWidth > 0) { + double inset = Math.Max(0.5D, effectiveStyle.BorderWidth * 0.5D); + content += FormatColor(effectiveStyle.BorderColor.Value) + " RG " + Format(effectiveStyle.BorderWidth) + " w " + + Format(inset) + " " + Format(inset) + " " + Format(Math.Max(0D, width - inset * 2D)) + " " + Format(Math.Max(0D, height - inset * 2D)) + " re S\n"; + } + + content += "BT /Helv " + Format(fontSize) + " Tf " + FormatColor(effectiveStyle.TextColor) + " rg " + Format(textX) + " " + Format(baseline) + " Td " + PdfSyntaxEscaper.WinAnsiHexString(clippedValue) + " Tj ET\n"; + return content + "Q\n"; } - internal static string BuildCheckBoxAppearanceContent(double width, double height, bool selected) { + internal static string BuildCheckBoxAppearanceContent(double width, double height, bool selected, PdfFormFieldStyle? style = null) { Guard.Positive(width, nameof(width)); Guard.Positive(height, nameof(height)); - double boxWidth = Math.Max(0D, width - 1D); - double boxHeight = Math.Max(0D, height - 1D); - string content = - "q\n" + - "1 1 1 rg 0 0 " + Format(width) + " " + Format(height) + " re f\n" + - "0.75 0.75 0.75 RG 0.5 0.5 " + Format(boxWidth) + " " + Format(boxHeight) + " re S\n"; + PdfFormFieldStyle effectiveStyle = style ?? new PdfFormFieldStyle(); + string content = "q\n"; + if (effectiveStyle.BackgroundColor.HasValue) { + content += FormatColor(effectiveStyle.BackgroundColor.Value) + " rg 0 0 " + Format(width) + " " + Format(height) + " re f\n"; + } + + if (effectiveStyle.BorderColor.HasValue && effectiveStyle.BorderWidth > 0) { + double inset = Math.Max(0.5D, effectiveStyle.BorderWidth * 0.5D); + content += FormatColor(effectiveStyle.BorderColor.Value) + " RG " + Format(effectiveStyle.BorderWidth) + " w " + + Format(inset) + " " + Format(inset) + " " + Format(Math.Max(0D, width - inset * 2D)) + " " + Format(Math.Max(0D, height - inset * 2D)) + " re S\n"; + } if (selected) { double markLeft = Math.Max(2D, width * 0.2D); @@ -94,7 +108,7 @@ internal static string BuildCheckBoxAppearanceContent(double width, double heigh double markLeftY = Math.Min(height - 2D, height * 0.52D); double markRightY = Math.Min(height - 2D, height * 0.78D); content += - "0 0 0 RG 1.25 w " + + FormatColor(effectiveStyle.MarkColor) + " RG 1.25 w " + Format(markLeft) + " " + Format(markLeftY) + " m " + Format(markMidX) + " " + Format(markMidY) + " l " + Format(markRight) + " " + Format(markRightY) + " l S\n"; @@ -103,5 +117,46 @@ internal static string BuildCheckBoxAppearanceContent(double width, double heigh return content + "Q\n"; } + internal static string BuildRadioButtonAppearanceContent(double width, double height, bool selected, PdfFormFieldStyle? style = null) { + Guard.Positive(width, nameof(width)); + Guard.Positive(height, nameof(height)); + + PdfFormFieldStyle effectiveStyle = style ?? new PdfFormFieldStyle(); + double centerX = width * 0.5D; + double centerY = height * 0.5D; + double radius = Math.Max(0D, Math.Min(width, height) * 0.5D - 0.75D); + double control = radius * 0.5522847498D; + string content = "q\n"; + if (effectiveStyle.BackgroundColor.HasValue) { + content += FormatColor(effectiveStyle.BackgroundColor.Value) + " rg 0 0 " + Format(width) + " " + Format(height) + " re f\n"; + } + + if (effectiveStyle.BorderColor.HasValue && effectiveStyle.BorderWidth > 0) { + content += FormatColor(effectiveStyle.BorderColor.Value) + " RG " + Format(effectiveStyle.BorderWidth) + " w " + + Format(centerX + radius) + " " + Format(centerY) + " m " + + Format(centerX + radius) + " " + Format(centerY + control) + " " + Format(centerX + control) + " " + Format(centerY + radius) + " " + Format(centerX) + " " + Format(centerY + radius) + " c " + + Format(centerX - control) + " " + Format(centerY + radius) + " " + Format(centerX - radius) + " " + Format(centerY + control) + " " + Format(centerX - radius) + " " + Format(centerY) + " c " + + Format(centerX - radius) + " " + Format(centerY - control) + " " + Format(centerX - control) + " " + Format(centerY - radius) + " " + Format(centerX) + " " + Format(centerY - radius) + " c " + + Format(centerX + control) + " " + Format(centerY - radius) + " " + Format(centerX + radius) + " " + Format(centerY - control) + " " + Format(centerX + radius) + " " + Format(centerY) + " c S\n"; + } + + if (selected) { + double dotRadius = Math.Max(0D, radius * 0.45D); + double dotControl = dotRadius * 0.5522847498D; + content += + FormatColor(effectiveStyle.MarkColor) + " rg " + + Format(centerX + dotRadius) + " " + Format(centerY) + " m " + + Format(centerX + dotRadius) + " " + Format(centerY + dotControl) + " " + Format(centerX + dotControl) + " " + Format(centerY + dotRadius) + " " + Format(centerX) + " " + Format(centerY + dotRadius) + " c " + + Format(centerX - dotControl) + " " + Format(centerY + dotRadius) + " " + Format(centerX - dotRadius) + " " + Format(centerY + dotControl) + " " + Format(centerX - dotRadius) + " " + Format(centerY) + " c " + + Format(centerX - dotRadius) + " " + Format(centerY - dotControl) + " " + Format(centerX - dotControl) + " " + Format(centerY - dotRadius) + " " + Format(centerX) + " " + Format(centerY - dotRadius) + " c " + + Format(centerX + dotControl) + " " + Format(centerY - dotRadius) + " " + Format(centerX + dotRadius) + " " + Format(centerY - dotControl) + " " + Format(centerX + dotRadius) + " " + Format(centerY) + " c f\n"; + } + + return content + "Q\n"; + } + + internal static string FormatColor(PdfColor color) => + Format(color.R) + " " + Format(color.G) + " " + Format(color.B); + private static string Format(double value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); } diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfAnnotationDictionaryBuilder.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfAnnotationDictionaryBuilder.cs index 9da4452d2..acd61369d 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfAnnotationDictionaryBuilder.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfAnnotationDictionaryBuilder.cs @@ -31,7 +31,7 @@ internal static string BuildGoToNamedDestinationLinkAnnotation(double x1, double " >> >>\n"; } - internal static string BuildTextFieldWidgetAnnotation(double x1, double y1, double x2, double y2, string name, string value, double fontSize, int normalAppearanceId) { + internal static string BuildTextFieldWidgetAnnotation(double x1, double y1, double x2, double y2, string name, string value, double fontSize, int normalAppearanceId, PdfFormFieldStyle? style = null) { ValidateRectangle(x1, y1, x2, y2); Guard.NotNullOrWhiteSpace(name, nameof(name)); Guard.NotNull(value, nameof(value)); @@ -52,13 +52,14 @@ internal static string BuildTextFieldWidgetAnnotation(double x1, double y1, doub FormatCoordinate(x2) + " " + FormatCoordinate(y2) + "] /F 4 /DA " + - PdfSyntaxEscaper.LiteralString("/Helv " + FormatCoordinate(fontSize) + " Tf 0 g") + - " /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N " + + PdfSyntaxEscaper.LiteralString("/Helv " + FormatCoordinate(fontSize) + " Tf " + PdfAcroFormDictionaryBuilder.FormatColor((style ?? new PdfFormFieldStyle()).TextColor) + " rg") + + BuildMkEntry(style) + + " /AP << /N " + PdfSyntaxEscaper.IndirectReference(normalAppearanceId) + " >> >>\n"; } - internal static string BuildCheckBoxWidgetAnnotation(double x1, double y1, double x2, double y2, string name, bool isChecked, string checkedValueName, int offAppearanceId, int checkedAppearanceId) { + internal static string BuildCheckBoxWidgetAnnotation(double x1, double y1, double x2, double y2, string name, bool isChecked, string checkedValueName, int offAppearanceId, int checkedAppearanceId, PdfFormFieldStyle? style = null) { ValidateRectangle(x1, y1, x2, y2); Guard.NotNullOrWhiteSpace(name, nameof(name)); Guard.NotNullOrWhiteSpace(checkedValueName, nameof(checkedValueName)); @@ -82,7 +83,8 @@ internal static string BuildCheckBoxWidgetAnnotation(double x1, double y1, doubl FormatCoordinate(y2) + "] /F 4 /AS /" + PdfSyntaxEscaper.Name(selectedName) + - " /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N << /Off " + + BuildMkEntry(style) + + " /AP << /N << /Off " + PdfSyntaxEscaper.IndirectReference(offAppearanceId) + " /" + PdfSyntaxEscaper.Name(checkedValueName) + @@ -91,10 +93,10 @@ internal static string BuildCheckBoxWidgetAnnotation(double x1, double y1, doubl " >> >> >>\n"; } - internal static string BuildChoiceFieldWidgetAnnotation(double x1, double y1, double x2, double y2, string name, IReadOnlyList options, string value, double fontSize, int normalAppearanceId, bool isComboBox) => - BuildChoiceFieldWidgetAnnotation(x1, y1, x2, y2, name, options, new[] { value }, fontSize, normalAppearanceId, isComboBox, allowsMultipleSelection: false); + internal static string BuildChoiceFieldWidgetAnnotation(double x1, double y1, double x2, double y2, string name, IReadOnlyList options, string value, double fontSize, int normalAppearanceId, bool isComboBox, PdfFormFieldStyle? style = null) => + BuildChoiceFieldWidgetAnnotation(x1, y1, x2, y2, name, options, new[] { value }, fontSize, normalAppearanceId, isComboBox, allowsMultipleSelection: false, style); - internal static string BuildChoiceFieldWidgetAnnotation(double x1, double y1, double x2, double y2, string name, IReadOnlyList options, IReadOnlyList values, double fontSize, int normalAppearanceId, bool isComboBox, bool allowsMultipleSelection) { + internal static string BuildChoiceFieldWidgetAnnotation(double x1, double y1, double x2, double y2, string name, IReadOnlyList options, IReadOnlyList values, double fontSize, int normalAppearanceId, bool isComboBox, bool allowsMultipleSelection, PdfFormFieldStyle? style = null) { ValidateRectangle(x1, y1, x2, y2); Guard.NotNullOrWhiteSpace(name, nameof(name)); Guard.NotNull(options, nameof(options)); @@ -163,12 +165,73 @@ internal static string BuildChoiceFieldWidgetAnnotation(double x1, double y1, do FormatCoordinate(x2) + " " + FormatCoordinate(y2) + "] /F 4 /DA " + - PdfSyntaxEscaper.LiteralString("/Helv " + FormatCoordinate(fontSize) + " Tf 0 g") + - " /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N " + + PdfSyntaxEscaper.LiteralString("/Helv " + FormatCoordinate(fontSize) + " Tf " + PdfAcroFormDictionaryBuilder.FormatColor((style ?? new PdfFormFieldStyle()).TextColor) + " rg") + + BuildMkEntry(style) + + " /AP << /N " + PdfSyntaxEscaper.IndirectReference(normalAppearanceId) + " >> >>\n"; } + internal static string BuildRadioButtonFieldDictionary(string name, IReadOnlyList options, string value, IReadOnlyList widgetObjectIds) { + Guard.NotNullOrWhiteSpace(name, nameof(name)); + Guard.NotNull(options, nameof(options)); + Guard.NotNullOrWhiteSpace(value, nameof(value)); + Guard.NotNull(widgetObjectIds, nameof(widgetObjectIds)); + ValidateRadioOptions(options, value); + if (widgetObjectIds.Count != options.Count) { + throw new ArgumentException("PDF radio button group requires one widget object per option.", nameof(widgetObjectIds)); + } + + var sb = new StringBuilder(); + sb.Append("<< /FT /Btn /T ") + .Append(PdfSyntaxEscaper.TextString(name)) + .Append(" /Ff 49152 /V /") + .Append(PdfSyntaxEscaper.Name(value)) + .Append(" /DV /") + .Append(PdfSyntaxEscaper.Name(value)) + .Append(" /Kids ["); + for (int i = 0; i < widgetObjectIds.Count; i++) { + sb.Append(' ') + .Append(PdfSyntaxEscaper.IndirectReference(widgetObjectIds[i])); + } + + sb.Append(" ] >>\n"); + return sb.ToString(); + } + + internal static string BuildRadioButtonWidgetAnnotation(double x1, double y1, double x2, double y2, int parentObjectId, string option, string value, int offAppearanceId, int selectedAppearanceId, PdfFormFieldStyle? style = null) { + ValidateRectangle(x1, y1, x2, y2); + if (parentObjectId <= 0) { + throw new ArgumentOutOfRangeException(nameof(parentObjectId), parentObjectId, "PDF radio button parent object id must be positive."); + } + + Guard.NotNullOrWhiteSpace(option, nameof(option)); + Guard.NotNullOrWhiteSpace(value, nameof(value)); + if (string.Equals(option, "Off", StringComparison.Ordinal)) { + throw new ArgumentException("PDF radio button option value cannot be Off.", nameof(option)); + } + + ValidateAsciiPdfNameValue(option, nameof(option), "PDF radio button option values must contain only ASCII PDF name characters."); + string stateName = string.Equals(option, value, StringComparison.Ordinal) ? option : "Off"; + return "<< /Type /Annot /Subtype /Widget /Parent " + + PdfSyntaxEscaper.IndirectReference(parentObjectId) + + " /Rect [" + + FormatCoordinate(x1) + " " + + FormatCoordinate(y1) + " " + + FormatCoordinate(x2) + " " + + FormatCoordinate(y2) + + "] /F 4 /AS /" + + PdfSyntaxEscaper.Name(stateName) + + BuildMkEntry(style) + + " /AP << /N << /Off " + + PdfSyntaxEscaper.IndirectReference(offAppearanceId) + + " /" + + PdfSyntaxEscaper.Name(option) + + " " + + PdfSyntaxEscaper.IndirectReference(selectedAppearanceId) + + " >> >> >>\n"; + } + private static string BuildChoiceValue(IReadOnlyList values, bool forceArray) { if (values.Count == 1 && !forceArray) { return PdfSyntaxEscaper.WinAnsiHexString(values[0]); @@ -193,14 +256,55 @@ private static string BuildContentsEntry(string? contents) => ? string.Empty : " /Contents " + PdfSyntaxEscaper.LiteralString(contents!); - private static void ValidateAsciiPdfNameValue(string value, string paramName) { + private static string BuildMkEntry(PdfFormFieldStyle? style) { + PdfFormFieldStyle effectiveStyle = style ?? new PdfFormFieldStyle(); + var sb = new StringBuilder(); + if (effectiveStyle.BorderColor.HasValue && effectiveStyle.BorderWidth > 0) { + sb.Append(" /BC [").Append(PdfAcroFormDictionaryBuilder.FormatColor(effectiveStyle.BorderColor.Value)).Append(']'); + } + + if (effectiveStyle.BackgroundColor.HasValue) { + sb.Append(" /BG [").Append(PdfAcroFormDictionaryBuilder.FormatColor(effectiveStyle.BackgroundColor.Value)).Append(']'); + } + + return sb.Length == 0 ? string.Empty : " /MK <<" + sb + " >>"; + } + + private static void ValidateRadioOptions(IReadOnlyList options, string value) { + if (options.Count == 0) { + throw new ArgumentException("PDF radio button group requires at least one option.", nameof(options)); + } + + var optionSet = new HashSet(StringComparer.Ordinal); + for (int i = 0; i < options.Count; i++) { + string option = options[i]; + Guard.NotNullOrWhiteSpace(option, nameof(options)); + if (string.Equals(option, "Off", StringComparison.Ordinal)) { + throw new ArgumentException("PDF radio button option value cannot be Off.", nameof(options)); + } + + ValidateAsciiPdfNameValue(option, nameof(options), "PDF radio button option values must contain only ASCII PDF name characters."); + if (!optionSet.Add(option)) { + throw new ArgumentException("PDF radio button options must be unique.", nameof(options)); + } + } + + if (!optionSet.Contains(value)) { + throw new ArgumentException("PDF radio button value must match the provided options.", nameof(value)); + } + } + + private static void ValidateAsciiPdfNameValue(string value, string paramName, string message) { for (int i = 0; i < value.Length; i++) { if (value[i] > 0x7E) { - throw new ArgumentException("PDF check box selected value name must contain only ASCII PDF name characters.", paramName); + throw new ArgumentException(message, paramName); } } } + private static void ValidateAsciiPdfNameValue(string value, string paramName) => + ValidateAsciiPdfNameValue(value, paramName, "PDF check box selected value name must contain only ASCII PDF name characters."); + private static void ValidateRectangle(double x1, double y1, double x2, double y2) { ValidateFinite(x1, nameof(x1)); ValidateFinite(y1, nameof(y1)); diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.ContentStream.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.ContentStream.cs index c831759e3..191458a79 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.ContentStream.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.ContentStream.cs @@ -97,6 +97,11 @@ public ContentStreamBuilder MoveTo(double x, double y) { return this; } + public ContentStreamBuilder PathSeparator() { + _sb.Append('\n'); + return this; + } + public ContentStreamBuilder LineTo(double x, double y) { _sb.Append(' ').Append(F(x)).Append(' ').Append(F(y)).Append(" l"); return this; diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Drawing.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Drawing.cs index c34bc09ad..b49ecb3c1 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Drawing.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Drawing.cs @@ -20,6 +20,59 @@ private static void DrawRowRect(StringBuilder sb, PdfColor color, double widthSt .RestoreState(); } + private static bool DrawPanelBorder(StringBuilder sb, PanelStyle style, double x, double y, double w, double h) { + if (!style.HasSideBorders) { + if (style.BorderColor.HasValue && style.BorderWidth > 0) { + DrawRowRect(sb, style.BorderColor.Value, style.BorderWidth, x, y, w, h); + return true; + } + + return false; + } + + bool drawn = false; + double x2 = x + w; + double y2 = y + h; + drawn |= DrawPanelHBorder(sb, ResolvePanelSideBorder(style.TopBorderSnapshot, style), x, x2, y2); + drawn |= DrawPanelVBorder(sb, ResolvePanelSideBorder(style.RightBorderSnapshot, style), x2, y2, y); + drawn |= DrawPanelHBorder(sb, ResolvePanelSideBorder(style.BottomBorderSnapshot, style), x, x2, y); + drawn |= DrawPanelVBorder(sb, ResolvePanelSideBorder(style.LeftBorderSnapshot, style), x, y2, y); + return drawn; + } + + private static PdfPanelBorder? ResolvePanelSideBorder(PdfPanelBorder? sideBorder, PanelStyle style) { + if (sideBorder != null) { + return sideBorder; + } + + if (!style.BorderColor.HasValue || style.BorderWidth <= 0) { + return null; + } + + return new PdfPanelBorder { + Color = style.BorderColor.Value, + Width = style.BorderWidth + }; + } + + private static bool DrawPanelHBorder(StringBuilder sb, PdfPanelBorder? border, double x1, double x2, double y) { + if (border?.Color == null || border.Width <= 0) { + return false; + } + + DrawHLine(sb, border.Color.Value, border.Width, x1, x2, y); + return true; + } + + private static bool DrawPanelVBorder(StringBuilder sb, PdfPanelBorder? border, double x, double yTop, double yBottom) { + if (border?.Color == null || border.Width <= 0) { + return false; + } + + DrawVLine(sb, border.Color.Value, border.Width, x, yTop, yBottom); + return true; + } + private static void DrawRectangle(StringBuilder sb, PdfColor? fillColor, PdfColor? strokeColor, double strokeWidth, OfficeIMO.Drawing.OfficeStrokeDashStyle strokeDashStyle, OfficeIMO.Drawing.OfficeStrokeLineCap? strokeLineCap, OfficeIMO.Drawing.OfficeStrokeLineJoin? strokeLineJoin, double x, double y, double w, double h) { if (!fillColor.HasValue && (!strokeColor.HasValue || strokeWidth <= 0)) { return; @@ -213,6 +266,10 @@ private static void AppendPathCommands(ContentStreamBuilder content, System.Coll var command = commands[i]; switch (command.Kind) { case OfficeIMO.Drawing.OfficePathCommandKind.MoveTo: + if (i > 0) { + content.PathSeparator(); + } + content.MoveTo(x + command.Point.X, y + h - command.Point.Y); break; case OfficeIMO.Drawing.OfficePathCommandKind.LineTo: @@ -239,6 +296,10 @@ private static void AppendLocalPathCommands(ContentStreamBuilder content, System var command = commands[i]; switch (command.Kind) { case OfficeIMO.Drawing.OfficePathCommandKind.MoveTo: + if (i > 0) { + content.PathSeparator(); + } + content.MoveTo(command.Point.X, command.Point.Y); break; case OfficeIMO.Drawing.OfficePathCommandKind.LineTo: @@ -597,21 +658,159 @@ private static void DrawHLine(StringBuilder sb, PdfColor color, double widthStro } private static void DrawCellBorder(StringBuilder sb, PdfCellBorder border, double x, double y, double w, double h) { - if (!border.Color.HasValue || border.Width <= 0) { + if (!border.Color.HasValue && + border.TopBorderSnapshot == null && + border.RightBorderSnapshot == null && + border.BottomBorderSnapshot == null && + border.LeftBorderSnapshot == null && + border.DiagonalUpBorderSnapshot == null && + border.DiagonalDownBorderSnapshot == null) { return; } - if (border.Top && border.Right && border.Bottom && border.Left) { + if (border.Color.HasValue && + border.Width > 0 && + border.DashStyle == OfficeIMO.Drawing.OfficeStrokeDashStyle.Solid && + border.LineStyle == PdfCellBorderLineStyle.Standard && + border.TopBorderSnapshot == null && + border.RightBorderSnapshot == null && + border.BottomBorderSnapshot == null && + border.LeftBorderSnapshot == null && + border.DiagonalUpBorderSnapshot == null && + border.DiagonalDownBorderSnapshot == null && + border.Top && + border.Right && + border.Bottom && + border.Left && + !border.DiagonalUp && + !border.DiagonalDown) { DrawRowRect(sb, border.Color.Value, border.Width, x, y, w, h); return; } double x2 = x + w; double y2 = y + h; - if (border.Top) DrawHLine(sb, border.Color.Value, border.Width, x, x2, y2); - if (border.Right) DrawVLine(sb, border.Color.Value, border.Width, x2, y2, y); - if (border.Bottom) DrawHLine(sb, border.Color.Value, border.Width, x, x2, y); - if (border.Left) DrawVLine(sb, border.Color.Value, border.Width, x, y2, y); + if (border.Top) DrawCellHBorder(sb, ResolveCellBorderSide(border.TopBorderSnapshot, border), x, x2, y2, -1D); + if (border.Right) DrawCellVBorder(sb, ResolveCellBorderSide(border.RightBorderSnapshot, border), x2, y2, y, -1D); + if (border.Bottom) DrawCellHBorder(sb, ResolveCellBorderSide(border.BottomBorderSnapshot, border), x, x2, y, 1D); + if (border.Left) DrawCellVBorder(sb, ResolveCellBorderSide(border.LeftBorderSnapshot, border), x, y2, y, 1D); + if (border.DiagonalUp) DrawCellDiagonalBorder(sb, ResolveCellBorderSide(border.DiagonalUpBorderSnapshot, border), x, y, x2, y2, diagonalUp: true); + if (border.DiagonalDown) DrawCellDiagonalBorder(sb, ResolveCellBorderSide(border.DiagonalDownBorderSnapshot, border), x, y, x2, y2, diagonalUp: false); + } + + private static bool HasRenderableCellBorder(PdfCellBorder? border) => + border != null && + ((border.Color.HasValue && border.Width > 0) || + IsRenderableCellBorderSide(border.TopBorderSnapshot) || + IsRenderableCellBorderSide(border.RightBorderSnapshot) || + IsRenderableCellBorderSide(border.BottomBorderSnapshot) || + IsRenderableCellBorderSide(border.LeftBorderSnapshot) || + (border.DiagonalUp && IsRenderableCellBorderSide(ResolveCellBorderSide(border.DiagonalUpBorderSnapshot, border))) || + (border.DiagonalDown && IsRenderableCellBorderSide(ResolveCellBorderSide(border.DiagonalDownBorderSnapshot, border)))); + + private static bool IsRenderableCellBorderSide(PdfCellBorderSide? border) => + border?.Color != null && border.Width > 0; + + private static PdfCellBorderSide? ResolveCellBorderSide(PdfCellBorderSide? sideBorder, PdfCellBorder border) { + if (sideBorder != null) { + return sideBorder; + } + + if (!border.Color.HasValue || border.Width <= 0) { + return null; + } + + return new PdfCellBorderSide { + Color = border.Color.Value, + Width = border.Width, + DashStyle = border.DashStyle, + LineStyle = border.LineStyle + }; + } + + private static void DrawCellHBorder(StringBuilder sb, PdfCellBorderSide? border, double x1, double x2, double y, double doubleLineDirection) { + if (border?.Color == null || border.Width <= 0) { + return; + } + + DrawStyledHLine(sb, border.Color.Value, border.Width, border.DashStyle, x1, x2, y); + if (border.LineStyle == PdfCellBorderLineStyle.TwoLine) { + DrawStyledHLine(sb, border.Color.Value, border.Width, border.DashStyle, x1, x2, y + doubleLineDirection * GetDoubleBorderGap(border.Width)); + } + } + + private static void DrawCellVBorder(StringBuilder sb, PdfCellBorderSide? border, double x, double yTop, double yBottom, double doubleLineDirection) { + if (border?.Color == null || border.Width <= 0) { + return; + } + + DrawStyledVLine(sb, border.Color.Value, border.Width, border.DashStyle, x, yTop, yBottom); + if (border.LineStyle == PdfCellBorderLineStyle.TwoLine) { + DrawStyledVLine(sb, border.Color.Value, border.Width, border.DashStyle, x + doubleLineDirection * GetDoubleBorderGap(border.Width), yTop, yBottom); + } + } + + private static void DrawCellDiagonalBorder(StringBuilder sb, PdfCellBorderSide? border, double x1, double y1, double x2, double y2, bool diagonalUp) { + if (border?.Color == null || border.Width <= 0) { + return; + } + + double startX = x1; + double startY = diagonalUp ? y1 : y2; + double endX = x2; + double endY = diagonalUp ? y2 : y1; + DrawStyledLine(sb, border.Color.Value, border.Width, border.DashStyle, startX, startY, endX, endY); + + if (border.LineStyle == PdfCellBorderLineStyle.TwoLine) { + double length = Math.Sqrt(Math.Pow(endX - startX, 2D) + Math.Pow(endY - startY, 2D)); + if (length > 0D) { + double gap = GetDoubleBorderGap(border.Width); + double offsetX = -(endY - startY) / length * gap; + double offsetY = (endX - startX) / length * gap; + DrawStyledLine(sb, border.Color.Value, border.Width, border.DashStyle, startX + offsetX, startY + offsetY, endX + offsetX, endY + offsetY); + } + } + } + + private static void DrawStyledHLine(StringBuilder sb, PdfColor color, double widthStroke, OfficeIMO.Drawing.OfficeStrokeDashStyle dashStyle, double x1, double x2, double y) { + ContentStreamBuilder content = new ContentStreamBuilder(sb) + .SaveState() + .StrokeColor(color) + .LineWidth(widthStroke); + ApplyStrokeDashStyle(content, dashStyle, widthStroke, hasExplicitLineCap: false); + content + .MoveTo(x1, y) + .LineTo(x2, y) + .StrokePath() + .RestoreState(); + } + + private static void DrawStyledLine(StringBuilder sb, PdfColor color, double widthStroke, OfficeIMO.Drawing.OfficeStrokeDashStyle dashStyle, double x1, double y1, double x2, double y2) { + ContentStreamBuilder content = new ContentStreamBuilder(sb) + .SaveState() + .StrokeColor(color) + .LineWidth(widthStroke); + ApplyStrokeDashStyle(content, dashStyle, widthStroke, hasExplicitLineCap: false); + content + .MoveTo(x1, y1) + .LineTo(x2, y2) + .StrokePath() + .RestoreState(); + } + + private static double GetDoubleBorderGap(double widthStroke) => Math.Max(widthStroke * 2D, 1D); + + private static void DrawStyledVLine(StringBuilder sb, PdfColor color, double widthStroke, OfficeIMO.Drawing.OfficeStrokeDashStyle dashStyle, double x, double yTop, double yBottom) { + ContentStreamBuilder content = new ContentStreamBuilder(sb) + .SaveState() + .StrokeColor(color) + .LineWidth(widthStroke); + ApplyStrokeDashStyle(content, dashStyle, widthStroke, hasExplicitLineCap: false); + content + .MoveTo(x, yTop) + .LineTo(x, yBottom) + .StrokePath() + .RestoreState(); } private static void WriteCell(StringBuilder sb, string fontRes, double fontSize, double x, double y, string text, PdfColor? color, PdfOptions opts) { diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Fonts.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Fonts.cs index 267d5096a..4d2e75c58 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Fonts.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Fonts.cs @@ -81,6 +81,31 @@ internal static partial class PdfWriter { _ => ThrowUnsupportedStandardFont(normal) }; + private static string GetStandardFontResourceName(PdfStandardFont font, PdfStandardFont defaultNormalFont) { + if (font == defaultNormalFont) return "F1"; + if (font == ChooseBold(defaultNormalFont)) return "F2"; + if (font == ChooseItalic(defaultNormalFont)) return "F3"; + if (font == ChooseBoldItalic(defaultNormalFont)) return "F4"; + + return GetIndependentStandardFontResourceName(font); + } + + private static string GetIndependentStandardFontResourceName(PdfStandardFont font) => font switch { + PdfStandardFont.Helvetica => "F11", + PdfStandardFont.HelveticaBold => "F12", + PdfStandardFont.HelveticaOblique => "F13", + PdfStandardFont.HelveticaBoldOblique => "F14", + PdfStandardFont.TimesRoman => "F15", + PdfStandardFont.TimesBold => "F16", + PdfStandardFont.TimesItalic => "F17", + PdfStandardFont.TimesBoldItalic => "F18", + PdfStandardFont.Courier => "F19", + PdfStandardFont.CourierBold => "F20", + PdfStandardFont.CourierOblique => "F21", + PdfStandardFont.CourierBoldOblique => "F22", + _ => ThrowUnsupportedStandardFontResource(font) + }; + private static double GlyphWidthEmFor(PdfStandardFont font) => font switch { PdfStandardFont.Courier or PdfStandardFont.CourierBold or PdfStandardFont.CourierOblique or PdfStandardFont.CourierBoldOblique => 0.6, PdfStandardFont.Helvetica or PdfStandardFont.HelveticaBold or PdfStandardFont.HelveticaOblique or PdfStandardFont.HelveticaBoldOblique => 0.55, @@ -596,4 +621,9 @@ private static double ThrowUnsupportedStandardFontWidth(PdfStandardFont font) { Guard.StandardFont(font, nameof(font), "PDF font must be one of the supported standard PDF fonts."); throw new System.ArgumentOutOfRangeException(nameof(font), "PDF font must be one of the supported standard PDF fonts."); } + + private static string ThrowUnsupportedStandardFontResource(PdfStandardFont font) { + Guard.StandardFont(font, nameof(font), "PDF font must be one of the supported standard PDF fonts."); + throw new System.ArgumentOutOfRangeException(nameof(font), "PDF font must be one of the supported standard PDF fonts."); + } } diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs index 60419df9b..ba08ac474 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs @@ -5,11 +5,12 @@ namespace OfficeIMO.Pdf; internal static partial class PdfWriter { private const double TableCellClipBleed = 2D; + private const double TableCellCheckBoxGap = 2D; // Helper shapes for column pagination private abstract class ColItem { public string Kind = string.Empty; } private sealed class ColPar : ColItem { public RichParagraphBlock Block = null!; public System.Collections.Generic.List> Lines = null!; public System.Collections.Generic.List Heights = null!; public double Leading; public double Size; public double XOffset; public double TextWidth; public double FirstLineXOffset; public double FirstLineTextWidth; public ColPar() { Kind = "P"; } } - private sealed class ColHead : ColItem { public HeadingBlock Block = null!; public System.Collections.Generic.List Lines = null!; public double Leading; public double Size; public double SpacingBefore; public double SpacingAfter; public bool KeepWithNext; public PdfColor? Color; public ColHead() { Kind = "H"; } } + private sealed class ColHead : ColItem { public HeadingBlock Block = null!; public System.Collections.Generic.List Lines = null!; public double Leading; public double Size; public double SpacingBefore; public double SpacingAfter; public bool Bold; public bool ApplySpacingBeforeAtTop; public bool KeepWithNext; public PdfColor? Color; public ColHead() { Kind = "H"; } } private sealed class ColRule : ColItem { public HorizontalRuleBlock Block = null!; public ColRule() { Kind = "R"; } } private sealed class ColImg : ColItem { public ImageBlock Block = null!; public ColImg() { Kind = "I"; } } private sealed class ColShape : ColItem { public ShapeBlock Block = null!; public ColShape() { Kind = "S"; } } @@ -17,27 +18,78 @@ private sealed class ColDrawing : ColItem { public DrawingBlock Block = null!; p private sealed class ColForm : ColItem { public IPdfBlock Block = null!; public ColForm() { Kind = "FORM"; } } private sealed class ColBookmark : ColItem { public BookmarkBlock Block = null!; public ColBookmark() { Kind = "B"; } } private sealed class ColSpacer : ColItem { public SpacerBlock Block = null!; public ColSpacer() { Kind = "SPACE"; } } - private sealed class ColListItem : ColItem { public System.Collections.Generic.List Lines = null!; public string Marker = string.Empty; public double MarkerXOffset; public double MarkerWidth; public PdfAlign MarkerAlign; public double TextXOffset; public double TextWidth; public PdfAlign TextAlign; public PdfColor? Color; public double Leading; public double Size; public double SpacingBefore; public double SpacingAfter; public bool KeepTogether; public bool IsFirstInKeepGroup; public double KeepGroupHeight; public bool KeepWithNext; public bool IsFirstInKeepWithNextGroup; public int KeepWithNextGroupItemCount; public double KeepWithNextGroupHeight; public ColListItem() { Kind = "L"; } } + private sealed class ColListItem : ColItem { public System.Collections.Generic.IReadOnlyList Runs = null!; public System.Collections.Generic.List> Lines = null!; public System.Collections.Generic.List Heights = null!; public string Marker = string.Empty; public double MarkerXOffset; public double MarkerWidth; public PdfAlign MarkerAlign; public double TextXOffset; public double TextWidth; public PdfAlign TextAlign; public PdfColor? Color; public double Leading; public double Size; public double SpacingBefore; public double SpacingAfter; public string? BookmarkName; public bool KeepTogether; public bool IsFirstInKeepGroup; public double KeepGroupHeight; public bool KeepWithNext; public bool IsFirstInKeepWithNextGroup; public int KeepWithNextGroupItemCount; public double KeepWithNextGroupHeight; public ColListItem() { Kind = "L"; } } private sealed class ColPanel : ColItem { public PanelParagraphBlock Block = null!; public PanelStyle Style = null!; public System.Collections.Generic.List> Lines = null!; public System.Collections.Generic.List Heights = null!; public double Leading; public double Size; public double FirstBaselineOffset; public double XOffset; public double PanelWidth; public double TextWidth; public ColPanel() { Kind = "PANEL"; } } - private sealed class ColTable : ColItem { public TableBlock Block = null!; public PdfTableStyle Style = null!; public int Columns; public double[] ColumnWidths = null!; public string[][][] RowLines = null!; public int[] RowLineCounts = null!; public double[] RowHeights = null!; public double[] RowLeadings = null!; public double[] RowSizes = null!; public bool[] RowBold = null!; public double Width; public double Size; public int HeaderRowCount; public int FooterStartRowIndex; public System.Collections.Generic.List? CaptionLines; public double CaptionLeading; public double CaptionHeight; public ColTable() { Kind = "T"; } } + private sealed class ColTable : ColItem { public TableBlock Block = null!; public PdfTableStyle Style = null!; public int Columns; public double[] ColumnWidths = null!; public TableCellTextLayout[][] RowLines = null!; public int[] RowLineCounts = null!; public double[] RowHeights = null!; public double[] RowLeadings = null!; public double[] RowSizes = null!; public bool[] RowBold = null!; public double Width; public double Size; public int HeaderRowCount; public int RepeatHeaderRowCount; public int FooterStartRowIndex; public System.Collections.Generic.List? CaptionLines; public double CaptionLeading; public double CaptionHeight; public ColTable() { Kind = "T"; } } private sealed class TableColumnLayout { public double[] Widths = null!; public double Width; } + private sealed class TableCellTextLayout { + public TableCellTextLayout(System.Collections.Generic.List> lines, System.Collections.Generic.List lineHeights) { + Lines = lines; + LineHeights = lineHeights; + } + + public System.Collections.Generic.List> Lines { get; } + public System.Collections.Generic.List LineHeights { get; } + public int LineCount => System.Math.Max(1, Lines.Count); + } private readonly struct TableCellLayout { - public TableCellLayout(int column, int columnSpan, int rowSpan, string text, string? linkUri, string? linkContents) { + public TableCellLayout(int column, int columnSpan, int rowSpan, string text, System.Collections.Generic.IReadOnlyList runs, string? linkUri, string? linkDestinationName, string? linkContents, string? namedDestinationName, System.Collections.Generic.IReadOnlyList checkBoxes, System.Collections.Generic.IReadOnlyList formFields, System.Collections.Generic.IReadOnlyList images) { Column = column; ColumnSpan = columnSpan; RowSpan = rowSpan; Text = text; + Runs = runs; LinkUri = linkUri; + LinkDestinationName = linkDestinationName; LinkContents = linkContents; + NamedDestinationName = namedDestinationName; + CheckBoxes = checkBoxes; + FormFields = formFields; + Images = images; } public int Column { get; } public int ColumnSpan { get; } public int RowSpan { get; } public string Text { get; } + public System.Collections.Generic.IReadOnlyList Runs { get; } public string? LinkUri { get; } + public string? LinkDestinationName { get; } public string? LinkContents { get; } + public string? NamedDestinationName { get; } + public System.Collections.Generic.IReadOnlyList CheckBoxes { get; } + public System.Collections.Generic.IReadOnlyList FormFields { get; } + public System.Collections.Generic.IReadOnlyList Images { get; } + } + + private static System.Collections.Generic.IReadOnlyList StripRunLinksWhenCellLinked(System.Collections.Generic.IReadOnlyList runs, string? linkUri, string? linkDestinationName) { + if (!HasCellLinkTarget(linkUri, linkDestinationName) || !runs.Any(run => run.LinkUri != null || run.LinkDestinationName != null)) { + return runs; + } + + var stripped = new System.Collections.Generic.List(runs.Count); + foreach (TextRun run in runs) { + stripped.Add(new TextRun( + run.Text, + run.Bold, + run.Underline, + run.Color, + run.Italic, + run.Strike, + run.FontSize, + run.Font, + baseline: run.Baseline, + tabLeader: run.TabLeader, + tabAlignment: run.TabAlignment, + backgroundColor: run.BackgroundColor)); + } + + return stripped; } + + private static bool HasCellLinkTarget(string? linkUri, string? linkDestinationName) => + !string.IsNullOrEmpty(linkUri) || !string.IsNullOrEmpty(linkDestinationName); + private static double GetParagraphLeading(PdfParagraphStyle? style, double fontSize) { double multiplier = style?.LineHeight ?? 1.4; if (multiplier <= 0 || double.IsNaN(multiplier) || double.IsInfinity(multiplier)) { @@ -90,6 +142,19 @@ private static double GetHeadingSpacingAfter(PdfHeadingStyle? style, double lead return style?.GetSpacingAfter(leading) ?? leading * 0.25D; } + private static bool GetHeadingBold(PdfHeadingStyle? style) { + return style?.Bold ?? true; + } + + private static PdfStandardFont GetHeadingFont(PdfOptions options, PdfHeadingStyle? style) { + var normalFont = ChooseNormal(options.DefaultFont); + return GetHeadingBold(style) ? ChooseBold(normalFont) : normalFont; + } + + private static string GetHeadingFontResource(PdfHeadingStyle? style) { + return GetHeadingBold(style) ? "F2" : "F1"; + } + private static PdfListStyle? ResolveListStyle(BulletListBlock block, PdfOptions options) { return block.Style ?? options.DefaultListStyleSnapshot; } @@ -143,6 +208,113 @@ private static double GetTableCellPaddingBottom(PdfTableStyle style) { return style.CellPaddingBottom ?? style.CellPaddingY; } + private static PdfCellPadding? GetTableCellPaddingOverride(PdfTableStyle style, int rowIndex, int columnIndex) { + if (style.CellPaddings != null && + style.CellPaddings.TryGetValue((rowIndex, columnIndex), out PdfCellPadding? padding)) { + return padding; + } + + return null; + } + + private static double GetTableCellPaddingLeft(PdfTableStyle style, int rowIndex, int columnIndex) { + return GetTableCellPaddingOverride(style, rowIndex, columnIndex)?.Left ?? GetTableCellPaddingLeft(style); + } + + private static double GetTableCellPaddingRight(PdfTableStyle style, int rowIndex, int columnIndex) { + return GetTableCellPaddingOverride(style, rowIndex, columnIndex)?.Right ?? GetTableCellPaddingRight(style); + } + + private static double GetTableCellPaddingTop(PdfTableStyle style, int rowIndex, int columnIndex) { + return GetTableCellPaddingOverride(style, rowIndex, columnIndex)?.Top ?? GetTableCellPaddingTop(style); + } + + private static double GetTableCellPaddingBottom(PdfTableStyle style, int rowIndex, int columnIndex) { + return GetTableCellPaddingOverride(style, rowIndex, columnIndex)?.Bottom ?? GetTableCellPaddingBottom(style); + } + + private static double GetTableRowMaxPaddingTop(TableBlock table, PdfTableStyle style, int rowIndex, int columnCount) { + double padding = GetTableCellPaddingTop(style); + var cells = GetTableCellLayouts(table, rowIndex, columnCount); + for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { + TableCellLayout cell = cells[cellIndex]; + padding = Math.Max(padding, GetTableCellPaddingTop(style, rowIndex, cell.Column)); + } + + return padding; + } + + private static double GetTableRowMaxPaddingBottom(TableBlock table, PdfTableStyle style, int rowIndex, int columnCount) { + double padding = GetTableCellPaddingBottom(style); + var cells = GetTableCellLayouts(table, rowIndex, columnCount); + for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { + TableCellLayout cell = cells[cellIndex]; + padding = Math.Max(padding, GetTableCellPaddingBottom(style, rowIndex, cell.Column)); + } + + return padding; + } + + private static double GetTableCellSpacing(PdfTableStyle style) { + double spacing = style.CellSpacing; + if (spacing < 0 || double.IsNaN(spacing) || double.IsInfinity(spacing)) { + throw new ArgumentException("Table cell spacing must be a non-negative finite value."); + } + + return spacing; + } + + private static PdfColumnAlign GetTableCellAlignment(PdfTableStyle style, int rowIndex, int columnIndex, string cellText) { + if (style.CellAlignments != null && + style.CellAlignments.TryGetValue((rowIndex, columnIndex), out PdfColumnAlign cellAlignment)) { + return cellAlignment; + } + + var alignment = PdfColumnAlign.Left; + if (style.Alignments != null && columnIndex < style.Alignments.Count) { + alignment = style.Alignments[columnIndex]; + } + + if (style.RightAlignNumeric && LooksNumeric(cellText)) { + return PdfColumnAlign.Right; + } + + return alignment; + } + + private static PdfCellVerticalAlign GetTableCellVerticalAlignment(PdfTableStyle style, int rowIndex, int columnIndex) { + if (style.CellVerticalAlignments != null && + style.CellVerticalAlignments.TryGetValue((rowIndex, columnIndex), out PdfCellVerticalAlign cellAlignment)) { + return cellAlignment; + } + + if (style.VerticalAlignments != null && columnIndex < style.VerticalAlignments.Count) { + return style.VerticalAlignments[columnIndex]; + } + + return PdfCellVerticalAlign.Top; + } + + private static double GetTableRowMinHeight(PdfTableStyle style, int rowIndex) { + if (style.RowMinHeights != null && + rowIndex < style.RowMinHeights.Count && + style.RowMinHeights[rowIndex].HasValue) { + return style.RowMinHeights[rowIndex]!.Value; + } + + return style.MinRowHeight; + } + + private static bool GetTableRowAllowBreakAcrossPages(PdfTableStyle style, int rowIndex) { + if (style.RowAllowBreakAcrossPages != null && + rowIndex < style.RowAllowBreakAcrossPages.Count && + style.RowAllowBreakAcrossPages[rowIndex].HasValue) { + return style.RowAllowBreakAcrossPages[rowIndex]!.Value; + } + + return style.AllowRowBreakAcrossPages; + } + private static int GetTableColumnCount(TableBlock table) => table.ColumnCount; private static void ValidateTableRoleRowCounts(PdfTableStyle style, int rowCount) { @@ -150,6 +322,11 @@ private static void ValidateTableRoleRowCounts(PdfTableStyle style, int rowCount throw new ArgumentException("Table header row count cannot exceed the table row count."); } + int repeatHeaderRowCount = GetTableRepeatHeaderRowCount(style); + if (repeatHeaderRowCount > style.HeaderRowCount) { + throw new ArgumentException("Table repeating header row count cannot exceed the table header row count."); + } + if (style.FooterRowCount > rowCount) { throw new ArgumentException("Table footer row count cannot exceed the table row count."); } @@ -183,8 +360,79 @@ private static void ValidateTableCellStyleCoordinates(PdfTableStyle style, int r } } } + + if (style.CellDataBars != null) { + foreach (var cellDataBar in style.CellDataBars) { + if (cellDataBar.Key.Row < 0 || cellDataBar.Key.Column < 0) { + throw new ArgumentException("Table cell data bar coordinates cannot be negative."); + } + + if (cellDataBar.Key.Row >= rowCount || cellDataBar.Key.Column >= columnCount) { + throw new ArgumentException("Table cell data bar coordinates must fit inside the table grid."); + } + } + } + + if (style.CellIcons != null) { + foreach (var cellIcon in style.CellIcons) { + if (cellIcon.Key.Row < 0 || cellIcon.Key.Column < 0) { + throw new ArgumentException("Table cell icon coordinates cannot be negative."); + } + + if (cellIcon.Key.Row >= rowCount || cellIcon.Key.Column >= columnCount) { + throw new ArgumentException("Table cell icon coordinates must fit inside the table grid."); + } + } + } + + if (style.CellPaddings != null) { + foreach (var cellPadding in style.CellPaddings) { + if (cellPadding.Key.Row < 0 || cellPadding.Key.Column < 0) { + throw new ArgumentException("Table cell padding coordinates cannot be negative."); + } + + if (cellPadding.Key.Row >= rowCount || cellPadding.Key.Column >= columnCount) { + throw new ArgumentException("Table cell padding coordinates must fit inside the table grid."); + } + } + } + + if (style.CellAlignments != null) { + foreach (var cellAlignment in style.CellAlignments) { + if (cellAlignment.Key.Row < 0 || cellAlignment.Key.Column < 0) { + throw new ArgumentException("Table cell alignment coordinates cannot be negative."); + } + + if (cellAlignment.Key.Row >= rowCount || cellAlignment.Key.Column >= columnCount) { + throw new ArgumentException("Table cell alignment coordinates must fit inside the table grid."); + } + + if (!IsValidPdfColumnAlign(cellAlignment.Value)) { + throw new ArgumentException("Table cell alignments must be Left, Center, or Right."); + } + } + } + + if (style.CellVerticalAlignments != null) { + foreach (var cellAlignment in style.CellVerticalAlignments) { + if (cellAlignment.Key.Row < 0 || cellAlignment.Key.Column < 0) { + throw new ArgumentException("Table cell vertical alignment coordinates cannot be negative."); + } + + if (cellAlignment.Key.Row >= rowCount || cellAlignment.Key.Column >= columnCount) { + throw new ArgumentException("Table cell vertical alignment coordinates must fit inside the table grid."); + } + + if (!IsValidPdfCellVerticalAlign(cellAlignment.Value)) { + throw new ArgumentException("Table cell vertical alignments must be defined PDF cell vertical alignment values."); + } + } + } } + private static int GetTableRepeatHeaderRowCount(PdfTableStyle style) => + style.RepeatHeaderRowCount ?? style.HeaderRowCount; + private static void ValidateTableColumnStyleBounds(PdfTableStyle style, int columnCount) { if (style.BodyColumnFills != null) { for (int column = columnCount; column < style.BodyColumnFills.Count; column++) { @@ -246,7 +494,7 @@ private static System.Collections.Generic.List GetTableCellLayo int columnSpan = System.Math.Min(cell.ColumnSpan, columnCount - column); int rowSpan = System.Math.Min(cell.RowSpan, table.Cells.Count - currentRow); if (currentRow == rowIndex) { - targetCells.Add(new TableCellLayout(column, columnSpan, rowSpan, cell.Text, cell.LinkUri, cell.LinkContents)); + targetCells.Add(new TableCellLayout(column, columnSpan, rowSpan, cell.Text, cell.Runs, cell.LinkUri, cell.LinkDestinationName, cell.LinkContents, cell.NamedDestinationName, cell.CheckBoxes, cell.FormFields, cell.Images)); } for (int c = column; c < column + columnSpan; c++) { @@ -279,17 +527,390 @@ private static double GetTableCellWidth(double[] columnWidths, int column, int c return width; } - private static double GetTableCellHeight(double[] rowHeights, int row, int rowSpan) { + private static double GetTableCellHeight(double[] rowHeights, int row, int rowSpan, double rowGap = 0D) { double height = 0D; int lastRow = System.Math.Min(rowHeights.Length, row + rowSpan); for (int index = row; index < lastRow; index++) { height += rowHeights[index]; + if (index > row) { + height += rowGap; + } + } + + return height; + } + + private static double GetTableRowGapAfter(int rowIndex, int rowCount, double rowGap) => + rowIndex < rowCount - 1 ? rowGap : 0D; + + private static double GetTableRowsHeight(double[] rowHeights, int startRow, int rowCount, double rowGap) { + double height = 0D; + int lastRow = System.Math.Min(rowHeights.Length, startRow + rowCount); + for (int rowIndex = startRow; rowIndex < lastRow; rowIndex++) { + height += rowHeights[rowIndex] + GetTableRowGapAfter(rowIndex, rowHeights.Length, rowGap); + } + + return height; + } + + private static TableCellTextLayout CreateTableCellTextLayout(TableCellLayout cell, double innerWidth, PdfStandardFont baseFont, double fontSize, double leading) { + var wrap = WrapRichRuns(cell.Runs, innerWidth, fontSize, baseFont, leading); + if (wrap.Lines.Count == 0) { + wrap.Lines.Add(new System.Collections.Generic.List()); + } + + while (wrap.LineHeights.Count < wrap.Lines.Count) { + wrap.LineHeights.Add(leading); + } + + return new TableCellTextLayout(wrap.Lines, wrap.LineHeights); + } + + private static TableCellTextLayout CreateListItemTextLayout(PdfListItem item, double innerWidth, PdfStandardFont baseFont, double fontSize, double leading) { + var wrap = WrapRichRuns(item.Runs, innerWidth, fontSize, baseFont, leading); + if (wrap.Lines.Count == 0) { + wrap.Lines.Add(new System.Collections.Generic.List()); + } + + while (wrap.LineHeights.Count < wrap.Lines.Count) { + wrap.LineHeights.Add(leading); + } + + return new TableCellTextLayout(wrap.Lines, wrap.LineHeights); + } + + private static double GetRichLineHeight(System.Collections.Generic.IReadOnlyList heights, int lineIndex, double fallbackLeading) => + lineIndex >= 0 && lineIndex < heights.Count ? heights[lineIndex] : fallbackLeading; + + private static double MeasureRichLinesHeight(System.Collections.Generic.IReadOnlyList heights, int lineCount, double fallbackLeading) { + double height = 0D; + for (int index = 0; index < lineCount; index++) { + height += GetRichLineHeight(heights, index, fallbackLeading); + } + + return height; + } + + private static double MeasureTableCellTextHeight(TableCellTextLayout layout, int startLine, int lineCount, double fallbackLeading) { + int available = System.Math.Max(0, layout.Lines.Count - startLine); + int visible = System.Math.Max(0, System.Math.Min(lineCount, available)); + if (visible == 0) { + return fallbackLeading; + } + + double height = 0D; + for (int i = 0; i < visible; i++) { + int lineIndex = startLine + i; + height += lineIndex < layout.LineHeights.Count ? layout.LineHeights[lineIndex] : fallbackLeading; + } + + return height; + } + + private static double MeasureTableCellObjectStackHeight(TableCellLayout cell) { + if (cell.Images.Count == 0 && cell.CheckBoxes.Count == 0 && cell.FormFields.Count == 0) { + return 0D; + } + + double height = 0D; + int objectCount = 0; + for (int index = 0; index < cell.Images.Count; index++) { + if (objectCount > 0) { + height += TableCellCheckBoxGap; + } + + height += cell.Images[index].Height; + objectCount++; + } + + for (int index = 0; index < cell.CheckBoxes.Count; index++) { + if (objectCount > 0) { + height += TableCellCheckBoxGap; + } + + height += cell.CheckBoxes[index].Size; + objectCount++; + } + + for (int index = 0; index < cell.FormFields.Count; index++) { + if (objectCount > 0) { + height += TableCellCheckBoxGap; + } + + height += cell.FormFields[index].Height; + objectCount++; } return height; } - private static void ApplyTableRowSpanHeights(TableBlock table, int columnCount, string[][][] rowLines, double[] rowHeights, double[] rowLeadings, double padTop, double padBottom) { + private static double MeasureTableCellContentHeight(TableCellLayout cell, TableCellTextLayout layout, int startLine, int lineCount, double fallbackLeading) { + double textHeight = MeasureTableCellTextHeight(layout, startLine, lineCount, fallbackLeading); + double objectStackHeight = MeasureTableCellObjectStackHeight(cell); + if (objectStackHeight <= 0D) { + return textHeight; + } + + if (CanRenderTableCellCheckBoxInline(cell, layout, startLine, lineCount)) { + return System.Math.Max(textHeight, cell.CheckBoxes[0].Size); + } + + if (string.IsNullOrEmpty(cell.Text)) { + return objectStackHeight; + } + + return textHeight + TableCellCheckBoxGap + objectStackHeight; + } + + private static double MeasureTableCellObjectWidth(TableCellLayout cell) { + double width = 0D; + for (int index = 0; index < cell.Images.Count; index++) { + width = System.Math.Max(width, cell.Images[index].Width); + } + + for (int index = 0; index < cell.CheckBoxes.Count; index++) { + width = System.Math.Max(width, cell.CheckBoxes[index].Size); + } + + for (int index = 0; index < cell.FormFields.Count; index++) { + width = System.Math.Max(width, cell.FormFields[index].Width); + } + + return width; + } + + private static bool CanRenderTableCellCheckBoxInline(TableCellLayout cell, TableCellTextLayout layout, int startLine, int lineCount) => + startLine == 0 && + lineCount > 0 && + cell.Images.Count == 0 && + cell.FormFields.Count == 0 && + cell.CheckBoxes.Count == 1 && + !string.IsNullOrWhiteSpace(cell.Text) && + layout.Lines.Count == 1 && + layout.Lines[0].Count > 0; + + private static void RenderTableCellInlineCheckBox(LayoutResult.Page page, TableCellLayout cell, PdfColumnAlign align, System.Collections.Generic.IReadOnlyList line, double textX, double innerWidth, double baselineY) { + PdfTableCellCheckBox checkBox = cell.CheckBoxes[0]; + double size = checkBox.Size; + double lineWidth = MeasureRichLineWidth(line); + double lineX = align switch { + PdfColumnAlign.Center => textX + System.Math.Max(0D, (innerWidth - lineWidth) / 2D), + PdfColumnAlign.Right => textX + System.Math.Max(0D, innerWidth - lineWidth), + _ => textX + }; + double x = System.Math.Min(textX + System.Math.Max(0D, innerWidth - size), lineX + lineWidth + TableCellCheckBoxGap); + double topY = baselineY + (size * 0.75D); + page.FormFields.Add(new FormFieldAnnotation { + X1 = x, + Y1 = topY - size, + X2 = x + size, + Y2 = topY, + Kind = FormFieldAnnotationKind.CheckBox, + Name = checkBox.Name, + Value = checkBox.IsChecked ? checkBox.CheckedValueName : "Off", + IsChecked = checkBox.IsChecked, + CheckedValueName = checkBox.CheckedValueName, + Style = checkBox.Style + }); + } + + private static void RenderTableCellObjects(LayoutResult.Page page, TableCellLayout cell, PdfColumnAlign align, double textX, double innerWidth, double topY) { + double yCursor = topY; + int objectCount = 0; + for (int index = 0; index < cell.Images.Count; index++) { + PdfTableCellImage image = cell.Images[index]; + if (objectCount > 0) { + yCursor -= TableCellCheckBoxGap; + } + + PdfAlign imageAlign = image.Style?.Align ?? MapTableCellAlignment(align); + ImageBlock block = image.ToImageBlock(imageAlign); + PdfImageStyle imageStyle = block.Style ?? new PdfImageStyle { + Align = imageAlign + }; + PdfDoc.ValidateImageStyleForBox(imageStyle, block.Width, block.Height, nameof(image.Style)); + PdfDoc.ValidateImageFitDimensions(block.Info, imageStyle.Fit, nameof(image.Style)); + double x = imageStyle.Align switch { + PdfAlign.Center => textX + System.Math.Max(0D, (innerWidth - block.Width) / 2D), + PdfAlign.Right => textX + System.Math.Max(0D, innerWidth - block.Width), + _ => textX + }; + PageImage pageImage = CreatePageImage(block, imageStyle, x, yCursor - block.Height); + page.Images.Add(pageImage); + AddTableCellImageLinkAnnotation(page, image, imageStyle, pageImage, x, yCursor - block.Height); + yCursor -= block.Height; + objectCount++; + } + + for (int index = 0; index < cell.CheckBoxes.Count; index++) { + PdfTableCellCheckBox checkBox = cell.CheckBoxes[index]; + if (objectCount > 0) { + yCursor -= TableCellCheckBoxGap; + } + + double size = checkBox.Size; + double x = align switch { + PdfColumnAlign.Center => textX + (innerWidth - size) / 2D, + PdfColumnAlign.Right => textX + innerWidth - size, + _ => textX + }; + page.FormFields.Add(new FormFieldAnnotation { + X1 = x, + Y1 = yCursor - size, + X2 = x + size, + Y2 = yCursor, + Kind = FormFieldAnnotationKind.CheckBox, + Name = checkBox.Name, + Value = checkBox.IsChecked ? checkBox.CheckedValueName : "Off", + IsChecked = checkBox.IsChecked, + CheckedValueName = checkBox.CheckedValueName, + Style = checkBox.Style + }); + yCursor -= size; + objectCount++; + } + + for (int index = 0; index < cell.FormFields.Count; index++) { + PdfTableCellFormField formField = cell.FormFields[index]; + if (objectCount > 0) { + yCursor -= TableCellCheckBoxGap; + } + + double width = System.Math.Min(formField.Width, innerWidth); + double x = align switch { + PdfColumnAlign.Center => textX + (innerWidth - width) / 2D, + PdfColumnAlign.Right => textX + innerWidth - width, + _ => textX + }; + + page.FormFields.Add(new FormFieldAnnotation { + X1 = x, + Y1 = yCursor - formField.Height, + X2 = x + width, + Y2 = yCursor, + Kind = formField.Kind == PdfTableCellFormFieldKind.Text ? FormFieldAnnotationKind.Text : FormFieldAnnotationKind.Choice, + Name = formField.Name, + Value = formField.Value, + Values = formField.Values, + FontSize = formField.FontSize, + Options = formField.Options, + IsComboBox = formField.IsComboBox, + AllowsMultipleSelection = false, + Style = formField.Style + }); + yCursor -= formField.Height; + objectCount++; + } + } + + private static void AddTableCellImageLinkAnnotation(LayoutResult.Page page, PdfTableCellImage image, PdfImageStyle style, PageImage pageImage, double targetX, double targetBottomY) { + if (string.IsNullOrEmpty(image.LinkUri)) { + return; + } + + double x1 = pageImage.X; + double y1 = pageImage.Y; + double x2 = pageImage.X + pageImage.W; + double y2 = pageImage.Y + pageImage.H; + if (style.Fit == OfficeImageFit.Cover || style.ClipPath != null) { + x1 = targetX; + y1 = targetBottomY; + x2 = targetX + image.Width; + y2 = targetBottomY + image.Height; + } + + page.Annotations.Add(new LinkAnnotation { X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, Uri = image.LinkUri!, Contents = image.LinkContents }); + } + + private static System.Collections.Generic.List> SliceTableCellLines(TableCellTextLayout layout, int startLine, int lineCount) { + var lines = new System.Collections.Generic.List>(); + int available = System.Math.Max(0, layout.Lines.Count - startLine); + int visible = System.Math.Max(0, System.Math.Min(lineCount, available)); + for (int i = 0; i < visible; i++) { + lines.Add(layout.Lines[startLine + i]); + } + + if (lines.Count == 0) { + lines.Add(new System.Collections.Generic.List()); + } + + return lines; + } + + private static System.Collections.Generic.List> StripRichLineLinksWhenCellLinked(System.Collections.Generic.List> lines, string? linkUri, string? linkDestinationName) { + if (!HasCellLinkTarget(linkUri, linkDestinationName) || !lines.Any(line => line.Any(segment => segment.Uri != null || segment.DestinationName != null))) { + return lines; + } + + var stripped = new System.Collections.Generic.List>(lines.Count); + foreach (System.Collections.Generic.List line in lines) { + var strippedLine = new System.Collections.Generic.List(line.Count); + foreach (RichSeg segment in line) { + strippedLine.Add(segment with { Uri = null, DestinationName = null, Contents = null }); + } + + stripped.Add(strippedLine); + } + + return stripped; + } + + private static System.Collections.Generic.List SliceTableCellLineHeights(TableCellTextLayout layout, int startLine, int lineCount, double fallbackLeading) { + var heights = new System.Collections.Generic.List(); + int available = System.Math.Max(0, layout.Lines.Count - startLine); + int visible = System.Math.Max(0, System.Math.Min(lineCount, available)); + for (int i = 0; i < visible; i++) { + int lineIndex = startLine + i; + heights.Add(lineIndex < layout.LineHeights.Count ? layout.LineHeights[lineIndex] : fallbackLeading); + } + + if (heights.Count == 0) { + heights.Add(fallbackLeading); + } + + return heights; + } + + private static PdfAlign MapTableCellAlignment(PdfColumnAlign align) => align switch { + PdfColumnAlign.Center => PdfAlign.Center, + PdfColumnAlign.Right => PdfAlign.Right, + _ => PdfAlign.Left + }; + + private static void ValidateTableCellTextWidths(TableBlock table, PdfTableStyle style, int columnCount, double[] columnWidths, double columnGap) { + for (int rowIndex = 0; rowIndex < table.Cells.Count; rowIndex++) { + var cells = GetTableCellLayouts(table, rowIndex, columnCount); + for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { + TableCellLayout cell = cells[cellIndex]; + double cellWidth = GetTableCellWidth(columnWidths, cell.Column, cell.ColumnSpan, columnGap); + double padLeft = GetTableCellPaddingLeft(style, rowIndex, cell.Column); + double padRight = GetTableCellPaddingRight(style, rowIndex, cell.Column); + if (cellWidth - padLeft - padRight <= 0.001) { + throw new ArgumentException("Table horizontal cell padding must leave a positive text width."); + } + } + } + } + + private static void ValidateTableRowStyleBounds(PdfTableStyle style, int rowCount) { + if (style.RowMinHeights != null) { + for (int row = rowCount; row < style.RowMinHeights.Count; row++) { + if (style.RowMinHeights[row].HasValue) { + throw new ArgumentException("Table row minimum heights must fit inside the table grid."); + } + } + } + + if (style.RowAllowBreakAcrossPages != null) { + for (int row = rowCount; row < style.RowAllowBreakAcrossPages.Count; row++) { + if (style.RowAllowBreakAcrossPages[row].HasValue) { + throw new ArgumentException("Table row break policies must fit inside the table grid."); + } + } + } + } + + private static void ApplyTableRowSpanHeights(TableBlock table, PdfTableStyle style, int columnCount, TableCellTextLayout[][] rowLines, double[] rowHeights, double[] rowLeadings, double rowGap) { for (int rowIndex = 0; rowIndex < table.Cells.Count; rowIndex++) { var cells = GetTableCellLayouts(table, rowIndex, columnCount); for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { @@ -303,9 +924,11 @@ private static void ApplyTableRowSpanHeights(TableBlock table, int columnCount, continue; } - string[] lines = rowLines[rowIndex][cell.Column]; - double requiredHeight = System.Math.Max(1, lines.Length) * rowLeadings[rowIndex] + padTop + padBottom; - double currentHeight = GetTableCellHeight(rowHeights, rowIndex, rowSpan); + TableCellTextLayout lines = rowLines[rowIndex][cell.Column]; + double requiredHeight = MeasureTableCellContentHeight(cell, lines, 0, lines.LineCount, rowLeadings[rowIndex]) + + GetTableCellPaddingTop(style, rowIndex, cell.Column) + + GetTableCellPaddingBottom(style, rowIndex, cell.Column); + double currentHeight = GetTableCellHeight(rowHeights, rowIndex, rowSpan, rowGap); if (requiredHeight <= currentHeight + 0.001) { continue; } @@ -523,6 +1146,123 @@ private static void DrawTableRowFill(StringBuilder sb, PdfColor color, double xO } } + private static bool DrawTableCellDataBars(StringBuilder sb, PdfTableStyle style, System.Collections.Generic.List cells, int rowIndex, int columnCount, double xOrigin, double yTop, double rowBottom, double rowHeight, double[] columnWidths, double columnGap, double[] rowHeights, double rowGap, bool wholeRowSegment, int startLine, bool[] skipColumns) { + if (style.CellDataBars == null || style.CellDataBars.Count == 0 || startLine != 0) { + return false; + } + + bool drawn = false; + double cellX = xOrigin; + for (int column = 0; column < columnCount; column++) { + if (style.CellDataBars.TryGetValue((rowIndex, column), out PdfCellDataBar? dataBar) && + dataBar.Ratio > 0D && + TryGetTableCellLayoutAtColumn(cells, column, out TableCellLayout cell) && + (column >= skipColumns.Length || !skipColumns[column])) { + int span = wholeRowSegment ? cell.ColumnSpan : 1; + double cellWidth = GetTableCellWidth(columnWidths, column, span, columnGap); + double cellHeight = rowHeight; + double cellBottom = rowBottom; + if (wholeRowSegment && cell.RowSpan > 1) { + cellHeight = GetTableCellHeight(rowHeights, rowIndex, cell.RowSpan, rowGap); + cellBottom = yTop - cellHeight; + } + + double padLeft = GetTableCellPaddingLeft(style, rowIndex, column); + double padRight = GetTableCellPaddingRight(style, rowIndex, column); + double padTop = GetTableCellPaddingTop(style, rowIndex, column); + double padBottom = GetTableCellPaddingBottom(style, rowIndex, column); + double barWidth = System.Math.Max(0D, cellWidth - padLeft - padRight) * dataBar.Ratio; + double barHeight = System.Math.Max(0D, cellHeight - padTop - padBottom); + if (barWidth > 0.001D && barHeight > 0.001D) { + DrawRowFill(sb, dataBar.Color, cellX + padLeft, cellBottom + padBottom, barWidth, barHeight); + drawn = true; + } + } + + cellX += columnWidths[column] + columnGap; + } + + return drawn; + } + + private static bool DrawTableCellIcons(StringBuilder sb, PdfTableStyle style, System.Collections.Generic.List cells, int rowIndex, int columnCount, double xOrigin, double yTop, double rowBottom, double rowHeight, double[] columnWidths, double columnGap, double[] rowHeights, double rowGap, bool wholeRowSegment, int startLine, bool[] skipColumns) { + if (style.CellIcons == null || style.CellIcons.Count == 0 || startLine != 0) { + return false; + } + + bool drawn = false; + double cellX = xOrigin; + for (int column = 0; column < columnCount; column++) { + if (style.CellIcons.TryGetValue((rowIndex, column), out PdfCellIcon? icon) && + TryGetTableCellLayoutAtColumn(cells, column, out TableCellLayout cell) && + (column >= skipColumns.Length || !skipColumns[column])) { + int span = wholeRowSegment ? cell.ColumnSpan : 1; + double cellWidth = GetTableCellWidth(columnWidths, column, span, columnGap); + double cellHeight = rowHeight; + double cellBottom = rowBottom; + if (wholeRowSegment && cell.RowSpan > 1) { + cellHeight = GetTableCellHeight(rowHeights, rowIndex, cell.RowSpan, rowGap); + cellBottom = yTop - cellHeight; + } + + double iconSize = Math.Min(icon.Size, Math.Max(1D, Math.Min(cellWidth, cellHeight) - 2D)); + if (iconSize > 0.001D) { + double padLeft = GetTableCellPaddingLeft(style, rowIndex, column); + double iconInset = Math.Max(1D, Math.Min(padLeft, 4D)); + double iconX = cellX + iconInset; + double iconY = cellBottom + Math.Max(0D, (cellHeight - iconSize) / 2D); + DrawTableCellIcon(sb, icon, iconX, iconY, iconSize); + drawn = true; + } + } + + cellX += columnWidths[column] + columnGap; + } + + return drawn; + } + + private static void DrawTableCellIcon(StringBuilder sb, PdfCellIcon icon, double x, double y, double size) { + var content = new ContentStreamBuilder(sb); + content.FillColor(icon.Color); + double midX = x + size / 2D; + double midY = y + size / 2D; + switch (icon.Kind) { + case PdfCellIconKind.Circle: + DrawFilledCircle(content, midX, midY, size / 2D); + break; + case PdfCellIconKind.Diamond: + content.MoveTo(midX, y + size).LineTo(x + size, midY).LineTo(midX, y).LineTo(x, midY).ClosePath().FillPath(); + break; + case PdfCellIconKind.Square: + content.Rectangle(x, y, size, size).FillPath(); + break; + case PdfCellIconKind.TriangleUp: + content.MoveTo(midX, y + size).LineTo(x + size, y).LineTo(x, y).ClosePath().FillPath(); + break; + case PdfCellIconKind.TriangleRight: + content.MoveTo(x + size, midY).LineTo(x, y + size).LineTo(x, y).ClosePath().FillPath(); + break; + case PdfCellIconKind.TriangleDown: + content.MoveTo(x, y + size).LineTo(x + size, y + size).LineTo(midX, y).ClosePath().FillPath(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(icon), icon.Kind, "PDF table cell icon kind is not supported."); + } + } + + private static void DrawFilledCircle(ContentStreamBuilder content, double centerX, double centerY, double radius) { + const double kappa = 0.5522847498307936D; + double control = radius * kappa; + content.MoveTo(centerX + radius, centerY) + .CubicTo(centerX + radius, centerY + control, centerX + control, centerY + radius, centerX, centerY + radius) + .CubicTo(centerX - control, centerY + radius, centerX - radius, centerY + control, centerX - radius, centerY) + .CubicTo(centerX - radius, centerY - control, centerX - control, centerY - radius, centerX, centerY - radius) + .CubicTo(centerX + control, centerY - radius, centerX + radius, centerY - control, centerX + radius, centerY) + .ClosePath() + .FillPath(); + } + private static bool HasSkippedColumns(bool[] skipColumns, int columnCount) { for (int column = 0; column < columnCount && column < skipColumns.Length; column++) { if (skipColumns[column]) { @@ -940,14 +1680,82 @@ private static double ResolveTableAvailableWidth(PdfTableStyle style, double con return frameWidth; } - private static TableColumnLayout ResolveTableColumnLayout(TableBlock table, PdfOptions options, PdfTableStyle style, int columns, double frameWidth, double fontSize, int headerRowCount, int footerStartRowIndex, double padLeft, double padRight) { + private static double FitFixedTableColumnsToAvailableWidth(double[] columnWidths, bool[] fixedColumns, double?[] minWidths, double fixedWidthTotal, double availableWidth) { + if (fixedWidthTotal <= availableWidth + 0.001D) { + return fixedWidthTotal; + } + + double requiredMinimumWidth = 0D; + for (int column = 0; column < columnWidths.Length; column++) { + if (fixedColumns[column] && minWidths[column].HasValue) { + requiredMinimumWidth += minWidths[column]!.Value; + } + } + + if (requiredMinimumWidth > availableWidth + 0.001D) { + throw new ArgumentException("Table fixed column widths cannot fit inside the available table width after applying minimum widths."); + } + + double[] originalWidths = new double[columnWidths.Length]; + bool[] lockedColumns = new bool[columnWidths.Length]; + double remainingOriginalWidth = 0D; + double remainingAvailableWidth = availableWidth; + for (int column = 0; column < columnWidths.Length; column++) { + if (!fixedColumns[column]) { + continue; + } + + originalWidths[column] = columnWidths[column]; + remainingOriginalWidth += columnWidths[column]; + } + + while (remainingOriginalWidth > 0.001D) { + double scale = remainingAvailableWidth / remainingOriginalWidth; + bool lockedMinimum = false; + + for (int column = 0; column < columnWidths.Length; column++) { + if (!fixedColumns[column] || lockedColumns[column]) { + continue; + } + + double candidateWidth = originalWidths[column] * scale; + if (minWidths[column].HasValue && candidateWidth < minWidths[column]!.Value - 0.001D) { + columnWidths[column] = minWidths[column]!.Value; + lockedColumns[column] = true; + remainingAvailableWidth -= columnWidths[column]; + remainingOriginalWidth -= originalWidths[column]; + lockedMinimum = true; + } + } + + if (!lockedMinimum) { + for (int column = 0; column < columnWidths.Length; column++) { + if (fixedColumns[column] && !lockedColumns[column]) { + columnWidths[column] = originalWidths[column] * scale; + } + } + + break; + } + } + + return fixedColumns.Select((fixedColumn, column) => fixedColumn ? columnWidths[column] : 0D).Sum(); + } + + private static TableColumnLayout ResolveTableColumnLayout(TableBlock table, PdfOptions options, PdfTableStyle style, int columns, double frameWidth, double fontSize, int headerRowCount, int footerStartRowIndex) { double[]? autoFitWeights = style.AutoFitColumns ? MeasureAutoFitColumnWeights(table, options, style, fontSize, headerRowCount, footerStartRowIndex) : null; double[]? autoFitMinimumWidths = style.AutoFitColumns ? MeasureAutoFitColumnMinimumWidths(table, options, style, fontSize, headerRowCount, footerStartRowIndex) : null; + double columnGap = GetTableCellSpacing(style); double tableWidth = ResolveTableAvailableWidth(style, frameWidth); + double tableInnerWidth = tableWidth - (columns - 1) * columnGap; + if (tableInnerWidth <= 0.001 || double.IsNaN(tableInnerWidth) || double.IsInfinity(tableInnerWidth)) { + throw new ArgumentException("Table cell spacing must leave a positive table width."); + } + double[] columnWidths = new double[columns]; double[] columnWeights = new double[columns]; bool[] fixedColumns = new bool[columns]; @@ -1006,22 +1814,16 @@ private static TableColumnLayout ResolveTableColumnLayout(TableBlock table, PdfO totalWeight += weight; } - if (fixedWidthTotal > tableWidth + 0.001) { - throw new ArgumentException("Table fixed column widths exceed the available table width."); - } + fixedWidthTotal = FitFixedTableColumnsToAvailableWidth(columnWidths, fixedColumns, minWidths, fixedWidthTotal, tableInnerWidth); - double remainingWidth = Math.Max(0D, tableWidth - fixedWidthTotal); + double remainingWidth = Math.Max(0D, tableInnerWidth - fixedWidthTotal); if (totalWeight <= 0D) { remainingWidth = 0D; } DistributeFlexibleColumns(columnWidths, columnWeights, fixedColumns, minWidths, maxWidths, remainingWidth); - tableWidth = Math.Min(tableWidth, columnWidths.Sum()); - for (int column = 0; column < columns; column++) { - if (columnWidths[column] - padLeft - padRight <= 0.001) { - throw new ArgumentException("Table horizontal cell padding must leave a positive text width."); - } - } + tableWidth = Math.Min(tableWidth, columnWidths.Sum() + (columns - 1) * columnGap); + ValidateTableCellTextWidths(table, style, columns, columnWidths, columnGap); return new TableColumnLayout { Widths = columnWidths, @@ -1098,8 +1900,10 @@ private static double[] MeasureAutoFitColumnWeights(TableBlock table, PdfOptions var cells = GetTableCellLayouts(table, rowIndex, cols); for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { TableCellLayout cell = cells[cellIndex]; - double measuredPoints = measurer.MeasureWidth(cell.Text, measurementStyle) * 72D / measurementStyle.Dpi; - double requestedWidth = Math.Max(1D, measuredPoints + GetTableCellPaddingLeft(style) + GetTableCellPaddingRight(style)); + double measuredPoints = System.Math.Max( + measurer.MeasureWidth(cell.Text, measurementStyle) * 72D / measurementStyle.Dpi, + MeasureTableCellObjectWidth(cell)); + double requestedWidth = Math.Max(1D, measuredPoints + GetTableCellPaddingLeft(style, rowIndex, cell.Column) + GetTableCellPaddingRight(style, rowIndex, cell.Column)); double requestedPerColumn = requestedWidth / cell.ColumnSpan; for (int c = cell.Column; c < cell.Column + cell.ColumnSpan && c < cols; c++) { if (requestedPerColumn > weights[c]) { @@ -1142,7 +1946,7 @@ private static double[] MeasureAutoFitColumnMinimumWidths(TableBlock table, PdfO } } - double requestedWidth = Math.Max(1D, Math.Min(tokenWidth, maximumTokenWidth) + GetTableCellPaddingLeft(style) + GetTableCellPaddingRight(style)); + double requestedWidth = Math.Max(1D, System.Math.Max(Math.Min(tokenWidth, maximumTokenWidth), MeasureTableCellObjectWidth(cell)) + GetTableCellPaddingLeft(style, rowIndex, cell.Column) + GetTableCellPaddingRight(style, rowIndex, cell.Column)); double requestedPerColumn = requestedWidth / cell.ColumnSpan; for (int columnIndex = cell.Column; columnIndex < cell.Column + cell.ColumnSpan && columnIndex < cols; columnIndex++) { if (requestedPerColumn > widths[columnIndex]) { @@ -1213,6 +2017,249 @@ private static PageImage CreatePageImage(ImageBlock block, PdfImageStyle style, }; } + private static void AddHeaderFooterImages(LayoutResult.Page page, PdfOptions options, int variantPageNumber) { + foreach (PdfHeaderFooterImage image in options.GetHeaderImagesForPage(variantPageNumber)) { + AddHeaderFooterImage(page, options, image, isHeader: true); + } + + foreach (PdfHeaderFooterImage image in options.GetFooterImagesForPage(variantPageNumber)) { + AddHeaderFooterImage(page, options, image, isHeader: false); + } + } + + private static void AddHeaderFooterImage(LayoutResult.Page page, PdfOptions options, PdfHeaderFooterImage image, bool isHeader) { + double contentLeft = options.MarginLeft; + double contentWidth = options.PageWidth - options.MarginLeft - options.MarginRight; + if (image.Width > contentWidth + 0.001D) { + throw new ArgumentException("PDF " + (isHeader ? "header" : "footer") + " image must fit inside the page content width."); + } + + double x = contentLeft; + if (image.Align == PdfAlign.Center) { + x = contentLeft + Math.Max(0D, (contentWidth - image.Width) / 2D); + } else if (image.Align == PdfAlign.Right) { + x = contentLeft + Math.Max(0D, contentWidth - image.Width); + } + + double y = isHeader + ? options.PageHeight - options.MarginTop + options.HeaderOffsetY - image.Height + : options.MarginBottom - options.FooterOffsetY; + if (y < -0.001D || y + image.Height > options.PageHeight + 0.001D) { + throw new ArgumentException("PDF " + (isHeader ? "header" : "footer") + " image must fit inside the page bounds."); + } + + ImageBlock block = image.ToImageBlock(); + page.Images.Add(CreatePageImage(block, block.Style ?? new PdfImageStyle(), x, y)); + } + + private static string BuildHeaderFooterShapes(LayoutResult.Page page, PdfOptions options, int variantPageNumber) { + var sb = new StringBuilder(); + foreach (PdfHeaderFooterShape shape in options.GetHeaderShapesForPage(variantPageNumber)) { + AddHeaderFooterShape(sb, page, options, shape, isHeader: true); + } + + foreach (PdfHeaderFooterShape shape in options.GetFooterShapesForPage(variantPageNumber)) { + AddHeaderFooterShape(sb, page, options, shape, isHeader: false); + } + + return sb.ToString(); + } + + private static void AddHeaderFooterShape(StringBuilder sb, LayoutResult.Page page, PdfOptions options, PdfHeaderFooterShape headerFooterShape, bool isHeader) { + ShapeBlock block = headerFooterShape.ToShapeBlock(); + PdfDrawingStyle style = block.Style ?? new PdfDrawingStyle(); + PdfDoc.ValidateDrawingStyle(style, isHeader ? "Header shape" : "Footer shape"); + + double contentLeft = options.MarginLeft; + double contentWidth = options.PageWidth - options.MarginLeft - options.MarginRight; + if (block.Shape.Width > contentWidth + 0.001D) { + throw new ArgumentException("PDF " + (isHeader ? "header" : "footer") + " shape must fit inside the page content width."); + } + + double x = GetHeaderFooterAlignedObjectX(contentLeft, contentWidth, block.Shape.Width, style.Align); + double bottomY = isHeader + ? options.PageHeight - options.MarginTop + options.HeaderOffsetY - block.Shape.Height + : options.MarginBottom - options.FooterOffsetY; + if (bottomY < -0.001D || bottomY + block.Shape.Height > options.PageHeight + 0.001D) { + throw new ArgumentException("PDF " + (isHeader ? "header" : "footer") + " shape must fit inside the page bounds."); + } + + DrawHeaderFooterShapeGeometryAt(sb, page, block.Shape, x, bottomY); + } + + private static double GetHeaderFooterAlignedObjectX(double containerX, double containerWidth, double objectWidth, PdfAlign align) { + if (align == PdfAlign.Center) return containerX + Math.Max(0, (containerWidth - objectWidth) / 2); + if (align == PdfAlign.Right) return containerX + Math.Max(0, containerWidth - objectWidth); + return containerX; + } + + private static PdfColor? ToHeaderFooterPdfColor(OfficeColor? color) => + color.HasValue ? PdfColor.FromOfficeColorOrNull(color.Value) : null; + + private static string? EnsureHeaderFooterGraphicsState(LayoutResult.Page page, double fillOpacity, double strokeOpacity) { + if (fillOpacity >= 1D && strokeOpacity >= 1D) { + return null; + } + + for (int i = 0; i < page.GraphicsStates.Count; i++) { + var existing = page.GraphicsStates[i]; + if (existing.FillOpacity.Equals(fillOpacity) && existing.StrokeOpacity.Equals(strokeOpacity)) { + return existing.Name; + } + } + + string name = "GS" + (page.GraphicsStates.Count + 1).ToString(CultureInfo.InvariantCulture); + page.GraphicsStates.Add(new PageGraphicsState { + Name = name, + FillOpacity = fillOpacity, + StrokeOpacity = strokeOpacity + }); + return name; + } + + private static string? EnsureHeaderFooterOpacityState(LayoutResult.Page page, OfficeShape shape) { + bool hasFill = (shape.FillColor.HasValue || shape.FillGradient != null) && shape.Kind != OfficeShapeKind.Line; + bool hasStroke = shape.StrokeColor.HasValue && shape.StrokeWidth > 0; + double fillOpacity = hasFill ? shape.FillOpacity ?? 1D : 1D; + double strokeOpacity = hasStroke ? shape.StrokeOpacity ?? 1D : 1D; + return EnsureHeaderFooterGraphicsState(page, fillOpacity, strokeOpacity); + } + + private static string? EnsureHeaderFooterLinearGradient(LayoutResult.Page page, OfficeShape shape, double xShape, double bottomY, bool localCoordinates) { + var gradient = shape.FillGradient; + if (gradient == null || shape.Kind == OfficeShapeKind.Line) { + return null; + } + + var start = gradient.Stops[0].Color; + var end = gradient.Stops[1].Color; + double originX = localCoordinates ? 0D : xShape; + double originY = localCoordinates ? 0D : bottomY; + double x0 = originX + gradient.StartX * shape.Width; + double y0 = originY + shape.Height - gradient.StartY * shape.Height; + double x1 = originX + gradient.EndX * shape.Width; + double y1 = originY + shape.Height - gradient.EndY * shape.Height; + + for (int i = 0; i < page.Shadings.Count; i++) { + var existing = page.Shadings[i]; + if (existing.StartColor.Equals(start) && + existing.EndColor.Equals(end) && + existing.X0.Equals(x0) && + existing.Y0.Equals(y0) && + existing.X1.Equals(x1) && + existing.Y1.Equals(y1)) { + return existing.Name; + } + } + + string name = "SH" + (page.Shadings.Count + 1).ToString(CultureInfo.InvariantCulture); + page.Shadings.Add(new PageShading { + Name = name, + StartColor = start, + EndColor = end, + X0 = x0, + Y0 = y0, + X1 = x1, + Y1 = y1 + }); + return name; + } + + private static void DrawHeaderFooterShapeShadowAt(StringBuilder sb, LayoutResult.Page page, OfficeShape shape, double xShape, double bottomY) { + var shadow = shape.Shadow; + if (shadow == null || shadow.Opacity <= 0D) { + return; + } + + PdfColor shadowColor = PdfColor.FromOfficeColor(shadow.Color); + double shadowX = xShape + shadow.OffsetX; + double shadowBottomY = bottomY - shadow.OffsetY; + string? shadowState = EnsureHeaderFooterGraphicsState(page, shadow.Opacity, shadow.Opacity); + + var content = new ContentStreamBuilder(sb) + .SaveState(); + if (shadowState != null) { + content.GraphicsState(shadowState); + } + + if (shape.Transform.HasValue) { + DrawTransformedShape( + sb, + shape, + shape.Kind == OfficeShapeKind.Line ? null : shadowColor, + shape.Kind == OfficeShapeKind.Line ? shadowColor : null, + null, + shadowX, + shadowBottomY); + } else if (shape.Kind == OfficeShapeKind.Line) { + DrawLine(sb, shadowColor, shape.StrokeWidth, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shape.Points, shadowX, shadowBottomY, shape.Height); + } else if (shape.Kind == OfficeShapeKind.RoundedRectangle) { + DrawRoundedRectangle(sb, shadowColor, null, 0, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shadowX, shadowBottomY, shape.Width, shape.Height, shape.CornerRadius); + } else if (shape.Kind == OfficeShapeKind.Rectangle) { + DrawRectangle(sb, shadowColor, null, 0, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shadowX, shadowBottomY, shape.Width, shape.Height); + } else if (shape.Kind == OfficeShapeKind.Ellipse) { + DrawEllipse(sb, shadowColor, null, 0, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shadowX, shadowBottomY, shape.Width, shape.Height); + } else if (shape.Kind == OfficeShapeKind.Polygon) { + DrawPolygon(sb, shadowColor, null, 0, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shape.Points, shadowX, shadowBottomY, shape.Height); + } else if (shape.Kind == OfficeShapeKind.Path) { + DrawPath(sb, shadowColor, null, 0, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shape.PathCommands, shadowX, shadowBottomY, shape.Height); + } + + content.RestoreState(); + } + + private static void DrawHeaderFooterShapeGeometryAt(StringBuilder sb, LayoutResult.Page page, OfficeShape shape, double xShape, double bottomY) { + DrawHeaderFooterShapeShadowAt(sb, page, shape, xShape, bottomY); + + string? opacityState = EnsureHeaderFooterOpacityState(page, shape); + if (opacityState != null) { + new ContentStreamBuilder(sb) + .SaveState() + .GraphicsState(opacityState); + } + + if (shape.Transform.HasValue) { + string? shadingName = EnsureHeaderFooterLinearGradient(page, shape, xShape, bottomY, localCoordinates: true); + DrawTransformedShape(sb, shape, shadingName == null ? ToHeaderFooterPdfColor(shape.FillColor) : null, ToHeaderFooterPdfColor(shape.StrokeColor), shadingName, xShape, bottomY); + } else { + if (shape.ClipPath != null) { + new ContentStreamBuilder(sb) + .SaveState(); + AppendClipPath(sb, shape.ClipPath, xShape, bottomY, shape.Height); + } + + string? shadingName = EnsureHeaderFooterLinearGradient(page, shape, xShape, bottomY, localCoordinates: false); + if (shadingName != null) { + DrawGradientShape(sb, shape, shadingName, xShape, bottomY); + } + + PdfColor? fillColor = shadingName == null ? ToHeaderFooterPdfColor(shape.FillColor) : null; + if (shape.Kind == OfficeShapeKind.Line) { + DrawLine(sb, ToHeaderFooterPdfColor(shape.StrokeColor), shape.StrokeWidth, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shape.Points, xShape, bottomY, shape.Height); + } else if (shape.Kind == OfficeShapeKind.RoundedRectangle) { + DrawRoundedRectangle(sb, fillColor, ToHeaderFooterPdfColor(shape.StrokeColor), shape.StrokeWidth, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, xShape, bottomY, shape.Width, shape.Height, shape.CornerRadius); + } else if (shape.Kind == OfficeShapeKind.Rectangle) { + DrawRectangle(sb, fillColor, ToHeaderFooterPdfColor(shape.StrokeColor), shape.StrokeWidth, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, xShape, bottomY, shape.Width, shape.Height); + } else if (shape.Kind == OfficeShapeKind.Ellipse) { + DrawEllipse(sb, fillColor, ToHeaderFooterPdfColor(shape.StrokeColor), shape.StrokeWidth, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, xShape, bottomY, shape.Width, shape.Height); + } else if (shape.Kind == OfficeShapeKind.Polygon) { + DrawPolygon(sb, fillColor, ToHeaderFooterPdfColor(shape.StrokeColor), shape.StrokeWidth, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shape.Points, xShape, bottomY, shape.Height); + } else if (shape.Kind == OfficeShapeKind.Path) { + DrawPath(sb, fillColor, ToHeaderFooterPdfColor(shape.StrokeColor), shape.StrokeWidth, shape.StrokeDashStyle, shape.StrokeLineCap, shape.StrokeLineJoin, shape.PathCommands, xShape, bottomY, shape.Height); + } + + if (shape.ClipPath != null) { + new ContentStreamBuilder(sb) + .RestoreState(); + } + } + + if (opacityState != null) { + new ContentStreamBuilder(sb) + .RestoreState(); + } + } + private static void DistributeFlexibleColumns( double[] widths, double[] weights, @@ -1308,6 +2355,7 @@ private static LayoutResult LayoutBlocks(IEnumerable blocks, PdfOptio bool usedBold = false; bool usedItalic = false; bool usedBoldItalic = false; + var emittedTableCellNamedDestinations = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); void StartPage(PdfOptions options) { options.Validate(); @@ -1374,8 +2422,8 @@ void WriteLinesInternal(string fontRes, double fontSize, double lineHeight, doub yStart2 -= GetDescender(lineFont, fontSize) * 0.0; } content.TextMatrix(x, yStart2); - var effectiveColor = color ?? currentOpts.DefaultTextColor; - if (effectiveColor.HasValue) content.FillColor(effectiveColor.Value); + var effectiveColor = color ?? currentOpts.DefaultTextColor ?? PdfColor.Black; + content.FillColor(effectiveColor); for (int i = 0; i < lines.Count; i++) { string line = lines[i]; double dx = 0; @@ -1394,7 +2442,7 @@ void WriteLines(string fontRes, double fontSize, double lineHeight, double x, do => WriteLinesInternal(fontRes, fontSize, lineHeight, x, width, startY, lines, align, color, applyBaselineTweak); void AddHeadingLinkAnnotations(HeadingBlock heading, System.Collections.Generic.IReadOnlyList lines, PdfStandardFont font, double fontSize, double lineHeight, double x, double widthUsed, double startBaselineY) { - if (string.IsNullOrEmpty(heading.LinkUri)) { + if (string.IsNullOrEmpty(heading.LinkUri) && string.IsNullOrEmpty(heading.LinkDestinationName)) { return; } @@ -1415,7 +2463,7 @@ void AddHeadingLinkAnnotations(HeadingBlock heading, System.Collections.Generic. double x2 = x1 + Math.Min(widthUsed, lineWidth); double y1 = baselineY - desc; double y2 = baselineY + asc; - currentPage!.Annotations.Add(new LinkAnnotation { X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, Uri = heading.LinkUri!, Contents = heading.LinkContents }); + currentPage!.Annotations.Add(new LinkAnnotation { X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, Uri = heading.LinkUri, DestinationName = heading.LinkDestinationName, Contents = heading.LinkContents }); } } @@ -1463,8 +2511,20 @@ void AddDrawingLinkAnnotation(DrawingBlock drawing, PdfDrawingStyle style, doubl } void AddNamedDestination(BookmarkBlock bookmark, double topY) { + AddNamedDestinationName(bookmark.Name, topY); + } + + void AddNamedDestinationName(string name, double topY) { EnsurePage(); - currentPage!.NamedDestinations.Add(new PageNamedDestination { Name = bookmark.Name, Y = topY }); + currentPage!.NamedDestinations.Add(new PageNamedDestination { Name = name, Y = topY }); + } + + void AddTableCellNamedDestinationName(string? name, double topY) { + if (string.IsNullOrWhiteSpace(name) || !emittedTableCellNamedDestinations.Add(name!)) { + return; + } + + AddNamedDestinationName(name!, topY); } double FirstTextBaselineFromTop(PdfStandardFont font, double fontSize, double topY) => @@ -1742,7 +2802,8 @@ void RenderTextFieldBlock(TextFieldBlock block, double containerX, double contai Kind = FormFieldAnnotationKind.Text, Name = block.Name, Value = block.Value, - FontSize = block.FontSize + FontSize = block.FontSize, + Style = block.Style }); pageDirty = true; y -= block.Height + block.SpacingAfter; @@ -1771,7 +2832,8 @@ void RenderCheckBoxBlock(CheckBoxBlock block, double containerX, double containe Name = block.Name, Value = block.IsChecked ? block.CheckedValueName : "Off", IsChecked = block.IsChecked, - CheckedValueName = block.CheckedValueName + CheckedValueName = block.CheckedValueName, + Style = block.Style }); pageDirty = true; y -= block.Size + block.SpacingAfter; @@ -1803,12 +2865,45 @@ void RenderChoiceFieldBlock(ChoiceFieldBlock block, double containerX, double co FontSize = block.FontSize, Options = block.Options, IsComboBox = block.IsComboBox, - AllowsMultipleSelection = block.AllowsMultipleSelection + AllowsMultipleSelection = block.AllowsMultipleSelection, + Style = block.Style }); pageDirty = true; y -= block.Height + block.SpacingAfter; } + void RenderRadioButtonGroupBlock(RadioButtonGroupBlock block, double containerX, double containerWidth) { + double spacingBefore = ResolveTopLevelSpacingBefore(block.SpacingBefore); + double height = block.Height; + double needed = spacingBefore + height + block.SpacingAfter; + EnsureFixedFlowBlockFits("Radio button group", block.Size, needed, containerWidth); + if (y - needed < currentOpts.MarginBottom) { + NewPage(); + spacingBefore = 0D; + } + + if (spacingBefore > 0) { + y -= spacingBefore; + } + + double x = GetAlignedObjectX(containerX, containerWidth, block.Size, block.Align); + currentPage!.FormFields.Add(new FormFieldAnnotation { + X1 = x, + Y1 = y - height, + X2 = x + block.Size, + Y2 = y, + Kind = FormFieldAnnotationKind.RadioButtonGroup, + Name = block.Name, + Value = block.Value, + Options = block.Options, + ButtonSize = block.Size, + ButtonGap = block.Gap, + Style = block.Style + }); + pageDirty = true; + y -= height + block.SpacingAfter; + } + static string GetFormFieldBlockName(IPdfBlock block) { if (block is TextFieldBlock) { return "Text field"; @@ -1818,6 +2913,10 @@ static string GetFormFieldBlockName(IPdfBlock block) { return "Check box"; } + if (block is RadioButtonGroupBlock) { + return "Radio button group"; + } + return "Choice field"; } @@ -1830,6 +2929,10 @@ static double GetFormFieldWidth(IPdfBlock block) { return checkBox.Size; } + if (block is RadioButtonGroupBlock radioButtonGroup) { + return radioButtonGroup.Size; + } + return ((ChoiceFieldBlock)block).Width; } @@ -1842,6 +2945,10 @@ static double GetFormFieldHeight(IPdfBlock block) { return checkBox.Size; } + if (block is RadioButtonGroupBlock radioButtonGroup) { + return radioButtonGroup.Height; + } + return ((ChoiceFieldBlock)block).Height; } @@ -1854,6 +2961,10 @@ static double GetFormFieldSpacingBefore(IPdfBlock block) { return checkBox.SpacingBefore; } + if (block is RadioButtonGroupBlock radioButtonGroup) { + return radioButtonGroup.SpacingBefore; + } + return ((ChoiceFieldBlock)block).SpacingBefore; } @@ -1866,6 +2977,10 @@ static double GetFormFieldSpacingAfter(IPdfBlock block) { return checkBox.SpacingAfter; } + if (block is RadioButtonGroupBlock radioButtonGroup) { + return radioButtonGroup.SpacingAfter; + } + return ((ChoiceFieldBlock)block).SpacingAfter; } @@ -1878,6 +2993,10 @@ static PdfAlign GetFormFieldAlign(IPdfBlock block) { return checkBox.Align; } + if (block is RadioButtonGroupBlock radioButtonGroup) { + return radioButtonGroup.Align; + } + return ((ChoiceFieldBlock)block).Align; } @@ -1891,7 +3010,8 @@ void AddFormFieldAnnotation(IPdfBlock block, double x, double topY) { Kind = FormFieldAnnotationKind.Text, Name = textField.Name, Value = textField.Value, - FontSize = textField.FontSize + FontSize = textField.FontSize, + Style = textField.Style }); return; } @@ -1906,7 +3026,25 @@ void AddFormFieldAnnotation(IPdfBlock block, double x, double topY) { Name = checkBox.Name, Value = checkBox.IsChecked ? checkBox.CheckedValueName : "Off", IsChecked = checkBox.IsChecked, - CheckedValueName = checkBox.CheckedValueName + CheckedValueName = checkBox.CheckedValueName, + Style = checkBox.Style + }); + return; + } + + if (block is RadioButtonGroupBlock radioButtonGroup) { + currentPage!.FormFields.Add(new FormFieldAnnotation { + X1 = x, + Y1 = topY - radioButtonGroup.Height, + X2 = x + radioButtonGroup.Size, + Y2 = topY, + Kind = FormFieldAnnotationKind.RadioButtonGroup, + Name = radioButtonGroup.Name, + Value = radioButtonGroup.Value, + Options = radioButtonGroup.Options, + ButtonSize = radioButtonGroup.Size, + ButtonGap = radioButtonGroup.Gap, + Style = radioButtonGroup.Style }); return; } @@ -1924,7 +3062,8 @@ void AddFormFieldAnnotation(IPdfBlock block, double x, double topY) { FontSize = choice.FontSize, Options = choice.Options, IsComboBox = choice.IsComboBox, - AllowsMultipleSelection = choice.AllowsMultipleSelection + AllowsMultipleSelection = choice.AllowsMultipleSelection, + Style = choice.Style }); } @@ -1985,7 +3124,25 @@ void ValidatePanelStyle(PanelStyle style, double panelWidth) { } } - void RenderListItem(System.Collections.Generic.List lines, string marker, double markerX, double markerWidth, PdfAlign markerAlign, double textX, double textWidth, PdfAlign textAlign, PdfColor? color, double size, double leading, double spacingBefore, double spacingAfter) { + void MarkRichFonts(System.Collections.Generic.IEnumerable runs) { + foreach (TextRun run in runs) { + PdfStandardFont runBaseFont = ChooseNormal(run.Font ?? currentOpts.DefaultFont); + PdfStandardFont runFont = run.Bold && run.Italic + ? ChooseBoldItalic(runBaseFont) + : run.Bold + ? ChooseBold(runBaseFont) + : run.Italic + ? ChooseItalic(runBaseFont) + : runBaseFont; + currentPage!.UsedFonts.Add(runFont); + } + + if (runs.Any(r => r.Bold)) { currentPage!.UsedBold = true; usedBold = true; } + if (runs.Any(r => r.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } + if (runs.Any(r => r.Bold && r.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + } + + void RenderListItem(System.Collections.Generic.IReadOnlyList runs, System.Collections.Generic.List> lines, System.Collections.Generic.List lineHeights, string marker, double markerX, double markerWidth, PdfAlign markerAlign, double textX, double textWidth, PdfAlign textAlign, PdfColor? color, double size, double leading, double spacingBefore, double spacingAfter, string? bookmarkName) { int lineIndex = 0; bool firstSegment = true; var listFont = ChooseNormal(currentOpts.DefaultFont); @@ -2001,10 +3158,11 @@ void RenderListItem(System.Collections.Generic.List lines, string marker while (lineIndex < lines.Count) { double available = y - currentOpts.MarginBottom; - if (available < leading) { + double firstLineHeight = GetRichLineHeight(lineHeights, lineIndex, leading); + if (available < firstLineHeight) { NewPage(); available = y - currentOpts.MarginBottom; - if (available < leading) { + if (available < firstLineHeight) { break; } } @@ -2012,11 +3170,12 @@ void RenderListItem(System.Collections.Generic.List lines, string marker int take = 0; double heightSum = 0; for (int k = lineIndex; k < lines.Count; k++) { - if (heightSum + leading > available) { + double lineHeight = GetRichLineHeight(lineHeights, k, leading); + if (heightSum + lineHeight > available) { break; } - heightSum += leading; + heightSum += lineHeight; take++; } @@ -2025,18 +3184,26 @@ void RenderListItem(System.Collections.Generic.List lines, string marker continue; } - var segmentLines = new System.Collections.Generic.List(take); + var segmentLines = new System.Collections.Generic.List>(take); + var segmentHeights = new System.Collections.Generic.List(take); for (int k = 0; k < take; k++) { segmentLines.Add(lines[lineIndex + k]); + segmentHeights.Add(GetRichLineHeight(lineHeights, lineIndex + k, leading)); } double baselineY = FirstTextBaselineFromTop(listFont, size, y); if (firstSegment) { + if (!string.IsNullOrEmpty(bookmarkName)) { + AddNamedDestinationName(bookmarkName!, y); + } + var markerLines = new System.Collections.Generic.List(1) { marker }; WriteLinesInternal("F1", size, leading, markerX, markerWidth, baselineY, markerLines, markerAlign, color, applyBaselineTweak: true); } - WriteLinesInternal("F1", size, leading, textX, textWidth, baselineY, segmentLines, textAlign, color, applyBaselineTweak: true); + pageDirty = true; + WriteRichParagraph(sb, new RichParagraphBlock(runs, textAlign, color), segmentLines, segmentHeights, currentOpts, baselineY, size, leading, currentPage!.Annotations, textX, textWidth); + MarkRichFonts(runs); y -= heightSum; lineIndex += take; firstSegment = false; @@ -2048,12 +3215,12 @@ void RenderListItem(System.Collections.Generic.List lines, string marker } } - double MeasureListKeepTogetherHeight(System.Collections.Generic.IReadOnlyList> itemLines, double leading, double spacingBefore, double itemSpacing, double spacingAfter) { + double MeasureListKeepTogetherHeight(System.Collections.Generic.IReadOnlyList itemLayouts, double leading, double spacingBefore, double itemSpacing, double spacingAfter) { double total = 0D; - for (int itemIndex = 0; itemIndex < itemLines.Count; itemIndex++) { + for (int itemIndex = 0; itemIndex < itemLayouts.Count; itemIndex++) { total += itemIndex == 0 ? spacingBefore : 0D; - total += itemLines[itemIndex].Count * leading; - total += itemIndex == itemLines.Count - 1 ? spacingAfter : itemSpacing; + total += MeasureRichLinesHeight(itemLayouts[itemIndex].LineHeights, itemLayouts[itemIndex].LineCount, leading); + total += itemIndex == itemLayouts.Count - 1 ? spacingAfter : itemSpacing; } return total; @@ -2133,15 +3300,17 @@ double MeasureNextBlockFirstVisualHeight(IPdfBlock block, double frameX, double double padRight = GetTableCellPaddingRight(style); double padTop = GetTableCellPaddingTop(style); double padBottom = GetTableCellPaddingBottom(style); + double columnGap = GetTableCellSpacing(style); ValidateTableRoleRowCounts(style, table.Rows.Count); int headerRowCount = style.HeaderRowCount; int footerRowCount = style.FooterRowCount; int footerStartRowIndex = table.Rows.Count - footerRowCount; ValidateTableCellStyleCoordinates(style, table.Rows.Count, columns); ValidateTableColumnStyleBounds(style, columns); + ValidateTableRowStyleBounds(style, table.Rows.Count); ValidateTableRowSpansWithinRoleBoundaries(table, columns, headerRowCount, footerStartRowIndex); double tableFontSize = GetTableBodyFontSize(style, fontSize); - TableColumnLayout columnLayout = ResolveTableColumnLayout(table, currentOpts, style, columns, frameWidth, tableFontSize, headerRowCount, footerStartRowIndex, padLeft, padRight); + TableColumnLayout columnLayout = ResolveTableColumnLayout(table, currentOpts, style, columns, frameWidth, tableFontSize, headerRowCount, footerStartRowIndex); double tableWidth = columnLayout.Width; double rowSize = GetTableRowFontSize(style, 0, headerRowCount, footerStartRowIndex, fontSize); double rowLeading = GetTableLeading(style, rowSize); @@ -2150,13 +3319,13 @@ double MeasureNextBlockFirstVisualHeight(IPdfBlock block, double frameX, double var firstRowCells = GetTableCellLayouts(table, 0, columns); for (int cellIndex = 0; cellIndex < firstRowCells.Count; cellIndex++) { TableCellLayout cell = firstRowCells[cellIndex]; - double cellWidth = GetTableCellWidth(columnLayout.Widths, cell.Column, cell.ColumnSpan, 0D); - double innerWidth = Math.Max(1D, cellWidth - padLeft - padRight); + double cellWidth = GetTableCellWidth(columnLayout.Widths, cell.Column, cell.ColumnSpan, columnGap); + double innerWidth = Math.Max(1D, cellWidth - GetTableCellPaddingLeft(style, 0, cell.Column) - GetTableCellPaddingRight(style, 0, cell.Column)); var lines = WrapSimpleText(cell.Text, innerWidth, GetTableRowFont(currentOpts, rowUsesBold), rowSize); maxLines = Math.Max(maxLines, lines.Count); } - double firstRowHeight = Math.Max(maxLines * rowLeading + padTop + padBottom, style.MinRowHeight); + double firstRowHeight = Math.Max(maxLines * rowLeading + GetTableRowMaxPaddingTop(table, style, 0, columns) + GetTableRowMaxPaddingBottom(table, style, 0, columns), GetTableRowMinHeight(style, 0)); double captionHeight = 0D; if (!string.IsNullOrWhiteSpace(style.Caption)) { double captionSize = style.CaptionFontSize ?? fontSize; @@ -2185,6 +3354,10 @@ double MeasureNextBlockFirstVisualHeight(IPdfBlock block, double frameX, double return choiceField.SpacingBefore + choiceField.Height + choiceField.SpacingAfter; } + if (block is RadioButtonGroupBlock radioButtonGroup) { + return radioButtonGroup.SpacingBefore + radioButtonGroup.Height + radioButtonGroup.SpacingAfter; + } + if (block is ImageBlock image) { PdfImageStyle style = ResolveImageStyle(image, currentOpts); return style.SpacingBefore + image.Height + style.SpacingAfter; @@ -2280,9 +3453,9 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { PdfHeadingStyle? headingStyle = ResolveHeadingStyle(hb, currentOpts); double size = GetHeadingFontSize(hb, headingStyle); double leading = GetHeadingLeading(headingStyle, size); - double spacingBefore = y < yStart - 0.001 ? headingStyle?.SpacingBefore ?? 0D : 0D; + double spacingBefore = (y < yStart - 0.001 || headingStyle?.ApplySpacingBeforeAtTop == true) ? headingStyle?.SpacingBefore ?? 0D : 0D; double spacingAfter = GetHeadingSpacingAfter(headingStyle, leading); - var headingFont = ChooseBold(ChooseNormal(currentOpts.DefaultFont)); + var headingFont = GetHeadingFont(currentOpts, headingStyle); var lines = WrapSimpleText(hb.Text, width, headingFont, size); double needed = spacingBefore + lines.Count * leading + spacingAfter; bool keepWithNext = headingStyle?.KeepWithNext ?? true; @@ -2291,15 +3464,15 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { double availableHeight = currentOpts.PageHeight - currentOpts.MarginTop - currentOpts.MarginBottom; if (keepHeight > needed + 0.001 && keepHeight <= availableHeight + 0.001 && y < yStart - 0.001 && y - keepHeight < currentOpts.MarginBottom) { NewPage(); - spacingBefore = 0D; - needed = lines.Count * leading + spacingAfter; + spacingBefore = headingStyle?.ApplySpacingBeforeAtTop == true ? headingStyle.SpacingBefore : 0D; + needed = spacingBefore + lines.Count * leading + spacingAfter; } } if (y - needed < currentOpts.MarginBottom) { NewPage(); - spacingBefore = 0D; - needed = lines.Count * leading + spacingAfter; + spacingBefore = headingStyle?.ApplySpacingBeforeAtTop == true ? headingStyle.SpacingBefore : 0D; + needed = spacingBefore + lines.Count * leading + spacingAfter; } if (spacingBefore > 0) { y -= spacingBefore; @@ -2310,9 +3483,12 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { } double firstBaseline = FirstTextBaselineFromTop(headingFont, size, y); AddHeadingLinkAnnotations(hb, lines, headingFont, size, leading, currentOpts.MarginLeft, width, firstBaseline); - WriteLines("F2", size, leading, currentOpts.MarginLeft, firstBaseline, lines, hb.Align, hb.Color ?? headingStyle?.Color, applyBaselineTweak: false); - currentPage!.UsedBold = true; - usedBold = true; + string headingFontResource = GetHeadingFontResource(headingStyle); + WriteLines(headingFontResource, size, leading, currentOpts.MarginLeft, firstBaseline, lines, hb.Align, hb.Color ?? headingStyle?.Color, applyBaselineTweak: false); + if (GetHeadingBold(headingStyle)) { + currentPage!.UsedBold = true; + usedBold = true; + } y -= lines.Count * leading + spacingAfter; } else if (block is RichParagraphBlock rpb) { double size = currentOpts.DefaultFontSize; @@ -2416,16 +3592,16 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { } } - if (rpb.Runs.Any(r => r.Bold)) { currentPage!.UsedBold = true; usedBold = true; } - if (rpb.Runs.Any(r => r.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } - if (rpb.Runs.Any(r => r.Bold && r.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + MarkRichFonts(rpb.Runs); } else if (block is BulletListBlock bl) { PdfListStyle? listStyle = ResolveListStyle(bl, currentOpts); double size = GetListFontSize(listStyle, currentOpts.DefaultFontSize); double leading = GetListLeading(listStyle, size); var baseFont = ChooseNormal(currentOpts.DefaultFont); const string bulletGlyph = "•"; - double bulletWidth = EstimateSimpleTextWidth(bulletGlyph, baseFont, size); + double bulletWidth = bl.RichItems.Count == 0 + ? EstimateSimpleTextWidth(bulletGlyph, baseFont, size) + : bl.RichItems.Max(item => EstimateSimpleTextWidth(item.Marker ?? bulletGlyph, baseFont, size)); double spaceAdvance = EstimateSimpleTextWidth(" ", baseFont, size); double markerGap = GetListMarkerGap(listStyle, spaceAdvance); double indent = bulletWidth + markerGap; @@ -2434,9 +3610,9 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { double availableWidth = Math.Max(rawTextWidth, EstimateSimpleTextWidth("WW", baseFont, size)); double alignmentWidth = Math.Max(0, rawTextWidth); double itemSpacing = GetListItemSpacing(listStyle, leading); - var wrappedItems = new System.Collections.Generic.List>(bl.Items.Count); - for (int itemIndex = 0; itemIndex < bl.Items.Count; itemIndex++) { - wrappedItems.Add(WrapSimpleText(bl.Items[itemIndex], availableWidth, baseFont, size)); + var wrappedItems = new System.Collections.Generic.List(bl.RichItems.Count); + for (int itemIndex = 0; itemIndex < bl.RichItems.Count; itemIndex++) { + wrappedItems.Add(CreateListItemTextLayout(bl.RichItems[itemIndex], availableWidth, baseFont, size, leading)); } double listSpacingBefore = ResolveTopLevelSpacingBefore(listStyle?.SpacingBefore ?? 0D); @@ -2466,25 +3642,31 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { } } - for (int itemIndex = 0; itemIndex < bl.Items.Count; itemIndex++) { - var lines = wrappedItems[itemIndex]; - double firstLineWidth = lines.Count > 0 ? EstimateSimpleTextWidth(lines[0], baseFont, size) : 0; + for (int itemIndex = 0; itemIndex < bl.RichItems.Count; itemIndex++) { + var item = bl.RichItems[itemIndex]; + string marker = item.Marker ?? bulletGlyph; + var layout = wrappedItems[itemIndex]; + double firstLineWidth = layout.Lines.Count > 0 ? MeasureRichLineWidth(layout.Lines[0]) : 0; double firstLineDx = 0; if (bl.Align == PdfAlign.Center) firstLineDx = Math.Max(0, (alignmentWidth - firstLineWidth) / 2); else if (bl.Align == PdfAlign.Right) firstLineDx = Math.Max(0, alignmentWidth - firstLineWidth); double spacingBefore = itemIndex == 0 ? listSpacingBefore : 0D; - double spacingAfter = itemIndex == bl.Items.Count - 1 ? listSpacingAfter : itemSpacing; - RenderListItem(lines, bulletGlyph, currentOpts.MarginLeft + listLeftIndent + firstLineDx, bulletWidth, PdfAlign.Left, currentOpts.MarginLeft + listLeftIndent + indent, alignmentWidth, bl.Align, bl.Color ?? listStyle?.Color, size, leading, spacingBefore, spacingAfter); + double spacingAfter = itemIndex == bl.RichItems.Count - 1 ? listSpacingAfter : itemSpacing; + RenderListItem(item.Runs, layout.Lines, layout.LineHeights, marker, currentOpts.MarginLeft + listLeftIndent + firstLineDx, bulletWidth, PdfAlign.Left, currentOpts.MarginLeft + listLeftIndent + indent, alignmentWidth, bl.Align, bl.Color ?? listStyle?.Color, size, leading, spacingBefore, spacingAfter, item.BookmarkName); } } else if (block is NumberedListBlock nl) { PdfListStyle? listStyle = ResolveListStyle(nl, currentOpts); double size = GetListFontSize(listStyle, currentOpts.DefaultFontSize); double leading = GetListLeading(listStyle, size); var baseFont = ChooseNormal(currentOpts.DefaultFont); - int lastNumber = nl.StartNumber + Math.Max(0, nl.Items.Count - 1); + int lastNumber = nl.StartNumber + Math.Max(0, nl.RichItems.Count - 1); string widestMarker = lastNumber.ToString(CultureInfo.InvariantCulture) + "."; - double markerWidth = EstimateSimpleTextWidth(widestMarker, baseFont, size); + double markerWidth = nl.RichItems.Count == 0 + ? EstimateSimpleTextWidth(widestMarker, baseFont, size) + : nl.RichItems + .Select((item, itemIndex) => item.Marker ?? ((nl.StartNumber + itemIndex).ToString(CultureInfo.InvariantCulture) + ".")) + .Max(marker => EstimateSimpleTextWidth(marker, baseFont, size)); double spaceAdvance = EstimateSimpleTextWidth(" ", baseFont, size); double markerGap = GetListMarkerGap(listStyle, spaceAdvance); double indent = markerWidth + markerGap; @@ -2493,9 +3675,9 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { double alignmentWidth = Math.Max(0, rawTextWidth); double itemSpacing = GetListItemSpacing(listStyle, leading); double listLeftIndent = listStyle?.LeftIndent ?? 0D; - var wrappedItems = new System.Collections.Generic.List>(nl.Items.Count); - for (int itemIndex = 0; itemIndex < nl.Items.Count; itemIndex++) { - wrappedItems.Add(WrapSimpleText(nl.Items[itemIndex], availableWidth, baseFont, size)); + var wrappedItems = new System.Collections.Generic.List(nl.RichItems.Count); + for (int itemIndex = 0; itemIndex < nl.RichItems.Count; itemIndex++) { + wrappedItems.Add(CreateListItemTextLayout(nl.RichItems[itemIndex], availableWidth, baseFont, size, leading)); } double listSpacingBefore = ResolveTopLevelSpacingBefore(listStyle?.SpacingBefore ?? 0D); @@ -2525,27 +3707,30 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { } } - for (int itemIndex = 0; itemIndex < nl.Items.Count; itemIndex++) { - string marker = (nl.StartNumber + itemIndex).ToString(CultureInfo.InvariantCulture) + "."; - var lines = wrappedItems[itemIndex]; - double firstLineWidth = lines.Count > 0 ? EstimateSimpleTextWidth(lines[0], baseFont, size) : 0; + for (int itemIndex = 0; itemIndex < nl.RichItems.Count; itemIndex++) { + var item = nl.RichItems[itemIndex]; + string marker = item.Marker ?? ((nl.StartNumber + itemIndex).ToString(CultureInfo.InvariantCulture) + "."); + var layout = wrappedItems[itemIndex]; + double firstLineWidth = layout.Lines.Count > 0 ? MeasureRichLineWidth(layout.Lines[0]) : 0; double firstLineDx = 0; if (nl.Align == PdfAlign.Center) firstLineDx = Math.Max(0, (alignmentWidth - firstLineWidth) / 2); else if (nl.Align == PdfAlign.Right) firstLineDx = Math.Max(0, alignmentWidth - firstLineWidth); double spacingBefore = itemIndex == 0 ? listSpacingBefore : 0D; - double spacingAfter = itemIndex == nl.Items.Count - 1 ? listSpacingAfter : itemSpacing; - RenderListItem(lines, marker, currentOpts.MarginLeft + listLeftIndent + firstLineDx, markerWidth, PdfAlign.Right, currentOpts.MarginLeft + listLeftIndent + indent, alignmentWidth, nl.Align, nl.Color ?? listStyle?.Color, size, leading, spacingBefore, spacingAfter); + double spacingAfter = itemIndex == nl.RichItems.Count - 1 ? listSpacingAfter : itemSpacing; + RenderListItem(item.Runs, layout.Lines, layout.LineHeights, marker, currentOpts.MarginLeft + listLeftIndent + firstLineDx, markerWidth, PdfAlign.Right, currentOpts.MarginLeft + listLeftIndent + indent, alignmentWidth, nl.Align, nl.Color ?? listStyle?.Color, size, leading, spacingBefore, spacingAfter, item.BookmarkName); } } else if (block is TableBlock tb) { - var style = tb.Style ?? currentOpts.DefaultTableStyleSnapshot ?? TableStyles.Light(); + PdfTableStyle style = tb.Style ?? currentOpts.DefaultTableStyleSnapshot ?? TableStyles.Light(); int cols = GetTableColumnCount(tb); if (cols == 0) continue; double padLeft = GetTableCellPaddingLeft(style); double padRight = GetTableCellPaddingRight(style); double padTop = GetTableCellPaddingTop(style); double padBottom = GetTableCellPaddingBottom(style); - double colGapPx = 0; + double cellSpacing = GetTableCellSpacing(style); + double colGapPx = cellSpacing; + double rowGapPx = cellSpacing; double size = GetTableBodyFontSize(style, currentOpts.DefaultFontSize); if (!IsValidPdfAlign(tb.Align)) { throw new ArgumentException("Table alignment must be Left, Center, or Right."); @@ -2588,6 +3773,13 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { if (style.MinRowHeight < 0 || double.IsNaN(style.MinRowHeight) || double.IsInfinity(style.MinRowHeight)) { throw new ArgumentException("Table minimum row height must be a non-negative finite value."); } + if (style.RowMinHeights != null) { + foreach (double? rowMinHeight in style.RowMinHeights) { + if (rowMinHeight.HasValue && (rowMinHeight.Value < 0 || double.IsNaN(rowMinHeight.Value) || double.IsInfinity(rowMinHeight.Value))) { + throw new ArgumentException("Table row minimum heights must be non-negative finite values."); + } + } + } if (style.SpacingBefore < 0 || double.IsNaN(style.SpacingBefore) || double.IsInfinity(style.SpacingBefore)) { throw new ArgumentException("Table spacing before must be a non-negative finite value."); } @@ -2632,10 +3824,12 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { ValidateTableRoleRowCounts(style, tb.Rows.Count); int headerRowCount = style.HeaderRowCount; + int repeatHeaderRowCount = GetTableRepeatHeaderRowCount(style); int footerRowCount = style.FooterRowCount; int footerStartRowIndex = tb.Rows.Count - footerRowCount; ValidateTableCellStyleCoordinates(style, tb.Rows.Count, cols); ValidateTableColumnStyleBounds(style, cols); + ValidateTableRowStyleBounds(style, tb.Rows.Count); ValidateTableRowSpansWithinRoleBoundaries(tb, cols, headerRowCount, footerStartRowIndex); double[]? autoFitWeights = style.AutoFitColumns ? MeasureAutoFitColumnWeights(tb, currentOpts, style, size, headerRowCount, footerStartRowIndex) @@ -2700,10 +3894,12 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { totalWeight += weight; } double tableInnerWidth = tableWidth - (cols - 1) * colGapPx; - if (fixedWidthTotal > tableInnerWidth + 0.001) { - throw new ArgumentException("Table fixed column widths exceed the available table width."); + if (tableInnerWidth <= 0.001 || double.IsNaN(tableInnerWidth) || double.IsInfinity(tableInnerWidth)) { + throw new ArgumentException("Table cell spacing must leave a positive table width."); } + fixedWidthTotal = FitFixedTableColumnsToAvailableWidth(colPixel, fixedColumns, minWidths, fixedWidthTotal, tableInnerWidth); + if (totalWeight <= 0) { tableInnerWidth = fixedWidthTotal; tableWidth = tableInnerWidth + (cols - 1) * colGapPx; @@ -2716,13 +3912,9 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { tableInnerWidth = usedTableInnerWidth; tableWidth = tableInnerWidth + (cols - 1) * colGapPx; } - for (int c = 0; c < cols; c++) { - if (colPixel[c] - padLeft - padRight <= 0.001) { - throw new ArgumentException("Table horizontal cell padding must leave a positive text width."); - } - } + ValidateTableCellTextWidths(tb, style, cols, colPixel, colGapPx); - var rowLines = new string[tb.Rows.Count][][]; + var rowLines = new TableCellTextLayout[tb.Rows.Count][]; var rowLineCounts = new int[tb.Rows.Count]; var rowHeights = new double[tb.Rows.Count]; var rowLeadings = new double[tb.Rows.Count]; @@ -2735,10 +3927,11 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { rowSizes[ri] = rowSize; rowLeadings[ri] = rowLeading; rowBold[ri] = rowUsesBold; - rowLines[ri] = new string[cols][]; + rowLines[ri] = new TableCellTextLayout[cols]; int maxLines = 1; + double maxRequiredHeight = rowLeading + GetTableRowMaxPaddingTop(tb, style, ri, cols) + GetTableRowMaxPaddingBottom(tb, style, ri, cols); for (int ci = 0; ci < cols; ci++) { - rowLines[ri][ci] = System.Array.Empty(); + rowLines[ri][ci] = new TableCellTextLayout(new System.Collections.Generic.List> { new() }, new System.Collections.Generic.List { rowLeading }); } var cells = GetTableCellLayouts(tb, ri, cols); @@ -2746,17 +3939,18 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { TableCellLayout cell = cells[cellIndex]; var cellFont = GetTableRowFont(currentOpts, rowUsesBold); double cellWidth = GetTableCellWidth(colPixel, cell.Column, cell.ColumnSpan, colGapPx); - double innerWidth = Math.Max(1, cellWidth - padLeft - padRight); - var lines = WrapSimpleText(cell.Text, innerWidth, cellFont, rowSize); - rowLines[ri][cell.Column] = lines.ToArray(); + double innerWidth = Math.Max(1, cellWidth - GetTableCellPaddingLeft(style, ri, cell.Column) - GetTableCellPaddingRight(style, ri, cell.Column)); + TableCellTextLayout lines = CreateTableCellTextLayout(cell, innerWidth, cellFont, rowSize, rowLeading); + rowLines[ri][cell.Column] = lines; if (cell.RowSpan <= 1) { - maxLines = Math.Max(maxLines, lines.Count); + maxLines = Math.Max(maxLines, lines.LineCount); + maxRequiredHeight = Math.Max(maxRequiredHeight, MeasureTableCellContentHeight(cell, lines, 0, lines.LineCount, rowLeading) + GetTableCellPaddingTop(style, ri, cell.Column) + GetTableCellPaddingBottom(style, ri, cell.Column)); } } rowLineCounts[ri] = maxLines; - rowHeights[ri] = Math.Max(maxLines * rowLeading + padTop + padBottom, style.MinRowHeight); + rowHeights[ri] = Math.Max(maxRequiredHeight, GetTableRowMinHeight(style, ri)); } - ApplyTableRowSpanHeights(tb, cols, rowLines, rowHeights, rowLeadings, padTop, padBottom); + ApplyTableRowSpanHeights(tb, style, cols, rowLines, rowHeights, rowLeadings, rowGapPx); double xOrigin = ResolveTableX(tb.Align, style, currentOpts.MarginLeft, contentWidth, tableWidth); double maxContentHeight = currentOpts.PageHeight - currentOpts.MarginTop - currentOpts.MarginBottom; @@ -2775,7 +3969,7 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { } } - double tableContentHeight = (captionLines == null ? 0 : captionHeight + style.CaptionSpacingAfter) + rowHeights.Sum(); + double tableContentHeight = (captionLines == null ? 0 : captionHeight + style.CaptionSpacingAfter) + GetTableRowsHeight(rowHeights, 0, rowHeights.Length, rowGapPx); double tableSpacingBefore = y < yStart - 0.001 ? style.SpacingBefore : 0D; if (style.KeepTogether) { double keepHeight = tableSpacingBefore + tableContentHeight + style.SpacingAfter; @@ -2826,10 +4020,10 @@ void ProcessBlocks(System.Collections.Generic.IEnumerable sequence) { usedBold = true; } - bool hasRepeatableHeader = headerRowCount > 0 && tb.Rows.Count > headerRowCount; + bool hasRepeatableHeader = repeatHeaderRowCount > 0 && tb.Rows.Count > headerRowCount; double repeatHeaderHeight = 0; - for (int i = 0; i < headerRowCount; i++) { - repeatHeaderHeight += rowHeights[i]; + for (int i = 0; i < repeatHeaderRowCount; i++) { + repeatHeaderHeight += rowHeights[i] + GetTableRowGapAfter(i, tb.Rows.Count, rowGapPx); } bool ShouldBreakBefore(double rowHeight) => @@ -2840,10 +4034,10 @@ bool ShouldBreakBefore(double rowHeight) => bool CanRepeatHeaderWithSegment(int rowIndex) => hasRepeatableHeader && rowIndex >= headerRowCount && - repeatHeaderHeight + rowLeadings[rowIndex] + padTop + padBottom <= maxContentHeight + 0.001; + repeatHeaderHeight + rowLeadings[rowIndex] + GetTableRowMaxPaddingTop(tb, style, rowIndex, cols) + GetTableRowMaxPaddingBottom(tb, style, rowIndex, cols) <= maxContentHeight + 0.001; void DrawRepeatHeaders() { - for (int headerIndex = 0; headerIndex < headerRowCount; headerIndex++) { + for (int headerIndex = 0; headerIndex < repeatHeaderRowCount; headerIndex++) { DrawTableRow(headerIndex, renderAsHeader: true); } } @@ -2867,7 +4061,9 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l var cells = GetTableCellLayouts(tb, rowIndex, cols); bool wholeRowSegment = startLine == 0 && lineCount == rowLineCounts[rowIndex]; - double rowHeight = wholeRowSegment ? rowHeights[rowIndex] : Math.Max(1, lineCount) * rowLeading + padTop + padBottom; + double rowPadTop = GetTableRowMaxPaddingTop(tb, style, rowIndex, cols); + double rowPadBottom = GetTableRowMaxPaddingBottom(tb, style, rowIndex, cols); + double rowHeight = wholeRowSegment ? rowHeights[rowIndex] : Math.Max(1, lineCount) * rowLeading + rowPadTop + rowPadBottom; double rowBottom = y - rowHeight; if (currentOpts.Debug?.ShowTableRowBoxes == true) { pageDirty = true; DrawRowRect(sb, new PdfColor(1, 0, 1), 0.6, xOrigin, rowBottom, tableWidth, rowHeight); } int bodyRowIndex = rowIndex - headerRowCount; @@ -2898,7 +4094,7 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l double fillBottom = rowBottom; if (wholeRowSegment) { if (fillCell.RowSpan > 1) { - fillHeight = GetTableCellHeight(rowHeights, rowIndex, fillCell.RowSpan); + fillHeight = GetTableCellHeight(rowHeights, rowIndex, fillCell.RowSpan, rowGapPx); fillBottom = y - fillHeight; } } @@ -2908,6 +4104,12 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l fillX += colPixel[fillColumn] + colGapPx; } } + if (style != null && DrawTableCellDataBars(sb, style, cells, rowIndex, cols, xOrigin, y, rowBottom, rowHeight, colPixel, colGapPx, rowHeights, rowGapPx, wholeRowSegment, startLine, rowFillSkips)) { + pageDirty = true; + } + if (style != null && DrawTableCellIcons(sb, style, cells, rowIndex, cols, xOrigin, y, rowBottom, rowHeight, colPixel, colGapPx, rowHeights, rowGapPx, wholeRowSegment, startLine, rowFillSkips)) { + pageDirty = true; + } if (currentOpts.Debug?.ShowTableBaselines == true) { double x1 = xOrigin; double x2 = xOrigin + tableWidth; @@ -2919,8 +4121,7 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l double yRect = rowBottom; double rowWidth = tableWidth; double hRect = rowHeight; - string fontRes = GetTableRowFontResource(rowUsesBold); - var textColor = renderAsHeader ? style?.HeaderTextColor : renderAsFooter ? style?.FooterTextColor : style?.TextColor; + var textColor = renderAsHeader ? style!.HeaderTextColor : renderAsFooter ? style!.FooterTextColor : style!.TextColor; for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { TableCellLayout cell = cells[cellIndex]; int c = cell.Column; @@ -2930,65 +4131,73 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l } double cellWidth = GetTableCellWidth(colPixel, c, cell.ColumnSpan, colGapPx); - double innerW = cellWidth - padLeft - padRight; - double cellHeight = wholeRowSegment && cell.RowSpan > 1 ? GetTableCellHeight(rowHeights, rowIndex, cell.RowSpan) : rowHeight; + double cellPadLeft = GetTableCellPaddingLeft(style, rowIndex, c); + double cellPadRight = GetTableCellPaddingRight(style, rowIndex, c); + double cellPadTop = GetTableCellPaddingTop(style, rowIndex, c); + double cellPadBottom = GetTableCellPaddingBottom(style, rowIndex, c); + double innerW = cellWidth - cellPadLeft - cellPadRight; + double cellHeight = wholeRowSegment && cell.RowSpan > 1 ? GetTableCellHeight(rowHeights, rowIndex, cell.RowSpan, rowGapPx) : rowHeight; double cellBottom = y - cellHeight; - var align = PdfColumnAlign.Left; - if (style?.Alignments != null && c < style.Alignments.Count) align = style.Alignments[c]; - if (style?.RightAlignNumeric == true && LooksNumeric(cell.Text)) align = PdfColumnAlign.Right; - var verticalAlign = PdfCellVerticalAlign.Top; - if (style?.VerticalAlignments != null && c < style.VerticalAlignments.Count) verticalAlign = style.VerticalAlignments[c]; + PdfColumnAlign align = GetTableCellAlignment(style, rowIndex, c, cell.Text); + PdfCellVerticalAlign verticalAlign = GetTableCellVerticalAlignment(style, rowIndex, c); var cellFont = GetTableRowFont(currentOpts, rowUsesBold); - var lines = rowLines[rowIndex][c]; + TableCellTextLayout lines = rowLines[rowIndex][c]; int sourceStartLine = wholeRowSegment && cell.RowSpan > 1 ? 0 : startLine; - int requestedLineCount = wholeRowSegment && cell.RowSpan > 1 ? lines.Length : lineCount; - int visibleLineCount = Math.Max(0, Math.Min(requestedLineCount, lines.Length - sourceStartLine)); + int requestedLineCount = wholeRowSegment && cell.RowSpan > 1 ? lines.LineCount : lineCount; + int visibleLineCount = Math.Max(0, Math.Min(requestedLineCount, lines.LineCount - sourceStartLine)); double verticalOffset = 0; + double visibleTextHeight = 0D; if (visibleLineCount > 0) { - double availableTextHeight = Math.Max(0, cellHeight - padTop - padBottom); - double visibleTextHeight = visibleLineCount * rowLeading; - double unusedTextHeight = Math.Max(0, availableTextHeight - visibleTextHeight); + double availableTextHeight = Math.Max(0, cellHeight - cellPadTop - cellPadBottom); + visibleTextHeight = MeasureTableCellTextHeight(lines, sourceStartLine, visibleLineCount, rowLeading); + double visibleContentHeight = MeasureTableCellContentHeight(cell, lines, sourceStartLine, visibleLineCount, rowLeading); + double unusedTextHeight = Math.Max(0, availableTextHeight - visibleContentHeight); if (verticalAlign == PdfCellVerticalAlign.Middle) verticalOffset = unusedTextHeight / 2; else if (verticalAlign == PdfCellVerticalAlign.Bottom) verticalOffset = unusedTextHeight; } - double firstBaseline = y - padTop - verticalOffset - GetAscender(cellFont, rowSize) + (style?.RowBaselineOffset ?? 0); + double firstBaseline = y - cellPadTop - verticalOffset - GetAscender(cellFont, rowSize) + style.RowBaselineOffset; pageDirty = true; - for (int lineIndex = 0; lineIndex < visibleLineCount; lineIndex++) { - int sourceLineIndex = sourceStartLine + lineIndex; - if (sourceLineIndex >= lines.Length) { - continue; - } - - string line = lines[sourceLineIndex]; - double textW = EstimateSimpleTextWidth(line, cellFont, rowSize); - double offset = 0; - if (align == PdfColumnAlign.Center) offset = Math.Max(0, (innerW - textW) / 2); - else if (align == PdfColumnAlign.Right) offset = Math.Max(0, innerW - textW); - double xCell = xi + padLeft + offset; - double yCell = firstBaseline - lineIndex * rowLeading; - if (ShouldClipTableCellText(xCell, yCell, textW, cellFont, rowSize, xi, cellBottom, cellWidth, cellHeight)) { - WriteClippedCell(sb, fontRes, rowSize, xCell, yCell, line, textColor, currentOpts, xi - TableCellClipBleed, cellBottom - TableCellClipBleed, cellWidth + TableCellClipBleed * 2D, cellHeight + TableCellClipBleed * 2D); - } else { - WriteCell(sb, fontRes, rowSize, xCell, yCell, line, textColor, currentOpts); - } - } - + if (cell.Runs.Any(run => run.Bold || rowUsesBold)) { currentPage!.UsedBold = true; usedBold = true; } + if (cell.Runs.Any(run => run.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } + if (cell.Runs.Any(run => (run.Bold || rowUsesBold) && run.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } string? linkUri = cell.LinkUri; + string? linkDestinationName = cell.LinkDestinationName; string? linkContents = cell.LinkContents; if (tb.Links.TryGetValue((rowIndex, c), out var uri)) { linkUri = uri; + linkDestinationName = null; linkContents = cell.Text; } - if (!string.IsNullOrEmpty(linkUri)) { - double x1 = xi + padLeft; - double x2 = xi + padLeft + innerW; - double y1 = cellBottom + padBottom; - double y2 = y - padTop; - currentPage!.Annotations.Add(new LinkAnnotation { X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, Uri = linkUri!, Contents = linkContents ?? cell.Text }); + if (sourceStartLine == 0) { + AddTableCellNamedDestinationName(cell.NamedDestinationName, y); + } + + if (visibleLineCount > 0) { + var visibleLines = SliceTableCellLines(lines, sourceStartLine, visibleLineCount); + visibleLines = StripRichLineLinksWhenCellLinked(visibleLines, linkUri, linkDestinationName); + var visibleHeights = SliceTableCellLineHeights(lines, sourceStartLine, visibleLineCount, rowLeading); + var paragraph = new RichParagraphBlock(StripRunLinksWhenCellLinked(cell.Runs, linkUri, linkDestinationName), MapTableCellAlignment(align), textColor); + WriteClippedRichParagraph(sb, paragraph, visibleLines, visibleHeights, currentOpts, firstBaseline, rowSize, rowLeading, currentPage!.Annotations, xi - TableCellClipBleed, cellBottom - TableCellClipBleed, cellWidth + (TableCellClipBleed * 2D), cellHeight + (TableCellClipBleed * 2D), xi + cellPadLeft, innerW); + } + if ((cell.Images.Count > 0 || cell.CheckBoxes.Count > 0 || cell.FormFields.Count > 0) && sourceStartLine == 0) { + if (CanRenderTableCellCheckBoxInline(cell, lines, sourceStartLine, visibleLineCount)) { + RenderTableCellInlineCheckBox(currentPage!, cell, align, lines.Lines[sourceStartLine], xi + cellPadLeft, innerW, firstBaseline); + } else { + double formFieldTop = y - cellPadTop - verticalOffset - (string.IsNullOrEmpty(cell.Text) ? 0D : visibleTextHeight + TableCellCheckBoxGap); + RenderTableCellObjects(currentPage!, cell, align, xi + cellPadLeft, innerW, formFieldTop); + } + } + + if (HasCellLinkTarget(linkUri, linkDestinationName)) { + double x1 = xi + cellPadLeft; + double x2 = xi + cellPadLeft + innerW; + double y1 = cellBottom + cellPadBottom; + double y2 = y - cellPadTop; + currentPage!.Annotations.Add(new LinkAnnotation { X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, Uri = linkUri, DestinationName = linkDestinationName, Contents = linkContents ?? cell.Text }); } } if (style?.BorderColor is not null && style.BorderWidth > 0) { @@ -3042,15 +4251,13 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l if (style.CellBorders.TryGetValue((rowIndex, borderColumn), out PdfCellBorder? cellBorder) && TryGetTableCellLayoutAtColumn(cells, borderColumn, out TableCellLayout borderCell) && (borderColumn >= rowFillSkips.Length || !rowFillSkips[borderColumn]) && - cellBorder != null && - cellBorder.Color.HasValue && - cellBorder.Width > 0) { + HasRenderableCellBorder(cellBorder)) { int span = wholeRowSegment ? borderCell.ColumnSpan : 1; double borderHeight = hRect; double borderBottom = yRect; if (wholeRowSegment) { if (borderCell.RowSpan > 1) { - borderHeight = GetTableCellHeight(rowHeights, rowIndex, borderCell.RowSpan); + borderHeight = GetTableCellHeight(rowHeights, rowIndex, borderCell.RowSpan, rowGapPx); borderBottom = y - borderHeight; } } @@ -3062,6 +4269,9 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l } } y -= rowHeight; + if (wholeRowSegment) { + y -= GetTableRowGapAfter(rowIndex, tb.Rows.Count, rowGapPx); + } } void DrawTableRow(int rowIndex, bool renderAsHeader) => @@ -3072,13 +4282,15 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { int totalLines = rowLineCounts[rowIndex]; while (startLine < totalLines) { double available = y - currentOpts.MarginBottom; - double minimumRowSegmentHeight = rowLeadings[rowIndex] + padTop + padBottom; + double rowPadTop = GetTableRowMaxPaddingTop(tb, style, rowIndex, cols); + double rowPadBottom = GetTableRowMaxPaddingBottom(tb, style, rowIndex, cols); + double minimumRowSegmentHeight = rowLeadings[rowIndex] + rowPadTop + rowPadBottom; if (available < minimumRowSegmentHeight - 0.001) { NewTablePage(rowIndex); available = y - currentOpts.MarginBottom; } - int maxLinesThisPage = Math.Max(1, (int)Math.Floor((available - padTop - padBottom) / rowLeadings[rowIndex])); + int maxLinesThisPage = Math.Max(1, (int)Math.Floor((available - rowPadTop - rowPadBottom) / rowLeadings[rowIndex])); int take = Math.Min(totalLines - startLine, maxLinesThisPage); DrawTableRowSegment(rowIndex, renderAsHeader && startLine == 0, startLine, take); startLine += take; @@ -3091,11 +4303,12 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { for (int rowIndex = 0; rowIndex < tb.Rows.Count; rowIndex++) { if (rowHeights[rowIndex] > maxContentHeight + 0.001) { - if (!style.AllowRowBreakAcrossPages) { + if (!GetTableRowAllowBreakAcrossPages(style, rowIndex)) { throw new ArgumentException("Table row height exceeds the available page content height and row splitting is disabled."); } DrawSplitTableRow(rowIndex, renderAsHeader: rowIndex < headerRowCount); + y -= GetTableRowGapAfter(rowIndex, tb.Rows.Count, rowGapPx); continue; } @@ -3126,6 +4339,8 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { RenderCheckBoxBlock(cbx, currentOpts.MarginLeft, width); } else if (block is ChoiceFieldBlock choice) { RenderChoiceFieldBlock(choice, currentOpts.MarginLeft, width); + } else if (block is RadioButtonGroupBlock radioButtonGroup) { + RenderRadioButtonGroupBlock(radioButtonGroup, currentOpts.MarginLeft, width); } else if (block is ShapeBlock sbk) { PdfDrawingStyle shapeStyle = ResolveDrawingStyle(sbk, currentOpts); PdfDoc.ValidateDrawingStyle(shapeStyle, "Shape"); @@ -3164,6 +4379,19 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { double xAcc = currentOpts.MarginLeft; for (int i = 0; i < ncols; i++) { double wCol = Math.Max(0, columnAreaWidth * (rb.Columns[i].WidthPercent / 100.0)); colXs[i] = xAcc; colWs[i] = wCol; xAcc += wCol + rowGap; } + void DrawRowColumnSeparators(double topY, double bottomY) { + if (ncols <= 1 || rowStyle?.ColumnSeparatorColor == null || rowStyle.ColumnSeparatorWidth <= 0D || topY - bottomY <= 0.001D) { + return; + } + + for (int boundary = 0; boundary < ncols - 1; boundary++) { + double separatorX = colXs[boundary] + colWs[boundary] + (rowGap / 2D); + DrawVLine(sb, rowStyle.ColumnSeparatorColor.Value, rowStyle.ColumnSeparatorWidth, separatorX, topY, bottomY); + } + + pageDirty = true; + } + var colStates = new System.Collections.Generic.List<(int idx, int line, int subline)>(ncols); var colItems = new System.Collections.Generic.List>(ncols); for (int i = 0; i < ncols; i++) { @@ -3174,7 +4402,7 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { PdfHeadingStyle? headingStyle = ResolveHeadingStyle(hb2, currentOpts); double size = GetHeadingFontSize(hb2, headingStyle); double leading = GetHeadingLeading(headingStyle, size); - var headingFont = ChooseBold(ChooseNormal(currentOpts.DefaultFont)); + var headingFont = GetHeadingFont(currentOpts, headingStyle); var lines = WrapSimpleText(hb2.Text, colWs[i], headingFont, size); items.Add(new ColHead { Block = hb2, @@ -3183,6 +4411,8 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { Size = size, SpacingBefore = headingStyle?.SpacingBefore ?? 0D, SpacingAfter = GetHeadingSpacingAfter(headingStyle, leading), + Bold = GetHeadingBold(headingStyle), + ApplySpacingBeforeAtTop = headingStyle?.ApplySpacingBeforeAtTop ?? false, KeepWithNext = headingStyle?.KeepWithNext ?? true, Color = hb2.Color ?? headingStyle?.Color }); @@ -3199,7 +4429,9 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { double leading = GetListLeading(listStyle, size); var baseFont = ChooseNormal(currentOpts.DefaultFont); const string bulletGlyph = "•"; - double bulletWidth = EstimateSimpleTextWidth(bulletGlyph, baseFont, size); + double bulletWidth = bl2.RichItems.Count == 0 + ? EstimateSimpleTextWidth(bulletGlyph, baseFont, size) + : bl2.RichItems.Max(item => EstimateSimpleTextWidth(item.Marker ?? bulletGlyph, baseFont, size)); double spaceAdvance = EstimateSimpleTextWidth(" ", baseFont, size); double markerGap = GetListMarkerGap(listStyle, spaceAdvance); double indent = bulletWidth + markerGap; @@ -3208,23 +4440,24 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { double availableWidth = Math.Max(rawTextWidth, EstimateSimpleTextWidth("WW", baseFont, size)); double alignmentWidth = Math.Max(0, rawTextWidth); double itemSpacing = GetListItemSpacing(listStyle, leading); - var listItems = new System.Collections.Generic.List(bl2.Items.Count); - for (int itemIndex = 0; itemIndex < bl2.Items.Count; itemIndex++) { - string text = bl2.Items[itemIndex]; - var lines = WrapSimpleText(text, availableWidth, baseFont, size); - double firstLineWidth = lines.Count > 0 ? EstimateSimpleTextWidth(lines[0], baseFont, size) : 0; + var listItems = new System.Collections.Generic.List(bl2.RichItems.Count); + for (int itemIndex = 0; itemIndex < bl2.RichItems.Count; itemIndex++) { + var item = bl2.RichItems[itemIndex]; + string marker = item.Marker ?? bulletGlyph; + var layout = CreateListItemTextLayout(item, availableWidth, baseFont, size, leading); + double firstLineWidth = layout.Lines.Count > 0 ? MeasureRichLineWidth(layout.Lines[0]) : 0; double firstLineDx = 0; if (bl2.Align == PdfAlign.Center) firstLineDx = Math.Max(0, (alignmentWidth - firstLineWidth) / 2); else if (bl2.Align == PdfAlign.Right) firstLineDx = Math.Max(0, alignmentWidth - firstLineWidth); double spacingBefore = itemIndex == 0 ? listStyle?.SpacingBefore ?? 0D : 0D; - double spacingAfter = itemIndex == bl2.Items.Count - 1 ? listStyle?.GetSpacingAfter(itemSpacing) ?? itemSpacing : itemSpacing; - listItems.Add(new ColListItem { Lines = lines, Marker = bulletGlyph, MarkerXOffset = listLeftIndent + firstLineDx, MarkerWidth = bulletWidth, MarkerAlign = PdfAlign.Left, TextXOffset = listLeftIndent + indent, TextWidth = alignmentWidth, TextAlign = bl2.Align, Color = bl2.Color ?? listStyle?.Color, Leading = leading, Size = size, SpacingBefore = spacingBefore, SpacingAfter = spacingAfter }); + double spacingAfter = itemIndex == bl2.RichItems.Count - 1 ? listStyle?.GetSpacingAfter(itemSpacing) ?? itemSpacing : itemSpacing; + listItems.Add(new ColListItem { Runs = item.Runs, Lines = layout.Lines, Heights = layout.LineHeights, Marker = marker, MarkerXOffset = listLeftIndent + firstLineDx, MarkerWidth = bulletWidth, MarkerAlign = PdfAlign.Left, TextXOffset = listLeftIndent + indent, TextWidth = alignmentWidth, TextAlign = bl2.Align, Color = bl2.Color ?? listStyle?.Color, Leading = leading, Size = size, SpacingBefore = spacingBefore, SpacingAfter = spacingAfter, BookmarkName = item.BookmarkName }); } if ((listStyle?.KeepTogether == true || listStyle?.KeepWithNext == true) && listItems.Count > 0) { double listGroupHeight = 0D; foreach (var listItem in listItems) { - listGroupHeight += listItem.SpacingBefore + listItem.Lines.Count * listItem.Leading + listItem.SpacingAfter; + listGroupHeight += listItem.SpacingBefore + MeasureRichLinesHeight(listItem.Heights, listItem.Lines.Count, listItem.Leading) + listItem.SpacingAfter; } if (listStyle?.KeepTogether == true) { @@ -3251,9 +4484,13 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { double size = GetListFontSize(listStyle, currentOpts.DefaultFontSize); double leading = GetListLeading(listStyle, size); var baseFont = ChooseNormal(currentOpts.DefaultFont); - int lastNumber = nl2.StartNumber + Math.Max(0, nl2.Items.Count - 1); + int lastNumber = nl2.StartNumber + Math.Max(0, nl2.RichItems.Count - 1); string widestMarker = lastNumber.ToString(CultureInfo.InvariantCulture) + "."; - double markerWidth = EstimateSimpleTextWidth(widestMarker, baseFont, size); + double markerWidth = nl2.RichItems.Count == 0 + ? EstimateSimpleTextWidth(widestMarker, baseFont, size) + : nl2.RichItems + .Select((item, itemIndex) => item.Marker ?? ((nl2.StartNumber + itemIndex).ToString(CultureInfo.InvariantCulture) + ".")) + .Max(marker => EstimateSimpleTextWidth(marker, baseFont, size)); double spaceAdvance = EstimateSimpleTextWidth(" ", baseFont, size); double markerGap = GetListMarkerGap(listStyle, spaceAdvance); double indent = markerWidth + markerGap; @@ -3262,24 +4499,24 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { double availableWidth = Math.Max(rawTextWidth, EstimateSimpleTextWidth("WW", baseFont, size)); double alignmentWidth = Math.Max(0, rawTextWidth); double itemSpacing = GetListItemSpacing(listStyle, leading); - var listItems = new System.Collections.Generic.List(nl2.Items.Count); - for (int itemIndex = 0; itemIndex < nl2.Items.Count; itemIndex++) { - string text = nl2.Items[itemIndex]; - string marker = (nl2.StartNumber + itemIndex).ToString(CultureInfo.InvariantCulture) + "."; - var lines = WrapSimpleText(text, availableWidth, baseFont, size); - double firstLineWidth = lines.Count > 0 ? EstimateSimpleTextWidth(lines[0], baseFont, size) : 0; + var listItems = new System.Collections.Generic.List(nl2.RichItems.Count); + for (int itemIndex = 0; itemIndex < nl2.RichItems.Count; itemIndex++) { + var item = nl2.RichItems[itemIndex]; + string marker = item.Marker ?? ((nl2.StartNumber + itemIndex).ToString(CultureInfo.InvariantCulture) + "."); + var layout = CreateListItemTextLayout(item, availableWidth, baseFont, size, leading); + double firstLineWidth = layout.Lines.Count > 0 ? MeasureRichLineWidth(layout.Lines[0]) : 0; double firstLineDx = 0; if (nl2.Align == PdfAlign.Center) firstLineDx = Math.Max(0, (alignmentWidth - firstLineWidth) / 2); else if (nl2.Align == PdfAlign.Right) firstLineDx = Math.Max(0, alignmentWidth - firstLineWidth); double spacingBefore = itemIndex == 0 ? listStyle?.SpacingBefore ?? 0D : 0D; - double spacingAfter = itemIndex == nl2.Items.Count - 1 ? listStyle?.GetSpacingAfter(itemSpacing) ?? itemSpacing : itemSpacing; - listItems.Add(new ColListItem { Lines = lines, Marker = marker, MarkerXOffset = listLeftIndent + firstLineDx, MarkerWidth = markerWidth, MarkerAlign = PdfAlign.Right, TextXOffset = listLeftIndent + indent, TextWidth = alignmentWidth, TextAlign = nl2.Align, Color = nl2.Color ?? listStyle?.Color, Leading = leading, Size = size, SpacingBefore = spacingBefore, SpacingAfter = spacingAfter }); + double spacingAfter = itemIndex == nl2.RichItems.Count - 1 ? listStyle?.GetSpacingAfter(itemSpacing) ?? itemSpacing : itemSpacing; + listItems.Add(new ColListItem { Runs = item.Runs, Lines = layout.Lines, Heights = layout.LineHeights, Marker = marker, MarkerXOffset = listLeftIndent + firstLineDx, MarkerWidth = markerWidth, MarkerAlign = PdfAlign.Right, TextXOffset = listLeftIndent + indent, TextWidth = alignmentWidth, TextAlign = nl2.Align, Color = nl2.Color ?? listStyle?.Color, Leading = leading, Size = size, SpacingBefore = spacingBefore, SpacingAfter = spacingAfter, BookmarkName = item.BookmarkName }); } if ((listStyle?.KeepTogether == true || listStyle?.KeepWithNext == true) && listItems.Count > 0) { double listGroupHeight = 0D; foreach (var listItem in listItems) { - listGroupHeight += listItem.SpacingBefore + listItem.Lines.Count * listItem.Leading + listItem.SpacingAfter; + listGroupHeight += listItem.SpacingBefore + MeasureRichLinesHeight(listItem.Heights, listItem.Lines.Count, listItem.Leading) + listItem.SpacingAfter; } if (listStyle?.KeepTogether == true) { @@ -3316,7 +4553,7 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { else if (panelStyle.Align == PdfAlign.Right) xOffset = Math.Max(0, colWs[i] - innerWidth); items.Add(new ColPanel { Block = ppb2, Style = panelStyle, Lines = wrap.Lines, Heights = wrap.LineHeights, Leading = leading, Size = size, FirstBaselineOffset = firstBaselineOffset, XOffset = xOffset, PanelWidth = innerWidth, TextWidth = textWidthAvail }); } else if (cb is TableBlock tb2) { - var style = tb2.Style ?? currentOpts.DefaultTableStyleSnapshot ?? TableStyles.Light(); + PdfTableStyle style = tb2.Style ?? currentOpts.DefaultTableStyleSnapshot ?? TableStyles.Light(); int cols = GetTableColumnCount(tb2); if (cols == 0) { continue; @@ -3326,13 +4563,18 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { double padRight = GetTableCellPaddingRight(style); double padTop = GetTableCellPaddingTop(style); double padBottom = GetTableCellPaddingBottom(style); + double cellSpacing = GetTableCellSpacing(style); + double columnGap = cellSpacing; + double tableRowGap = cellSpacing; double size = currentOpts.DefaultFontSize; ValidateTableRoleRowCounts(style, tb2.Rows.Count); int headerRowCount = style.HeaderRowCount; + int repeatHeaderRowCount = GetTableRepeatHeaderRowCount(style); int footerRowCount = style.FooterRowCount; int footerStartRowIndex = tb2.Rows.Count - footerRowCount; ValidateTableCellStyleCoordinates(style, tb2.Rows.Count, cols); ValidateTableColumnStyleBounds(style, cols); + ValidateTableRowStyleBounds(style, tb2.Rows.Count); ValidateTableRowSpansWithinRoleBoundaries(tb2, cols, headerRowCount, footerStartRowIndex); double[]? autoFitWeights = style.AutoFitColumns ? MeasureAutoFitColumnWeights(tb2, currentOpts, style, size, headerRowCount, footerStartRowIndex) @@ -3388,24 +4630,23 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { } double tableAvailableWidth = ResolveTableAvailableWidth(style, colWs[i]); - if (fixedWidthTotal > tableAvailableWidth + 0.001) { - throw new ArgumentException("Table fixed column widths exceed the available table width."); + double tableInnerAvailableWidth = tableAvailableWidth - (cols - 1) * columnGap; + if (tableInnerAvailableWidth <= 0.001 || double.IsNaN(tableInnerAvailableWidth) || double.IsInfinity(tableInnerAvailableWidth)) { + throw new ArgumentException("Table cell spacing must leave a positive table width."); } - double remainingWidth = Math.Max(0, tableAvailableWidth - fixedWidthTotal); + fixedWidthTotal = FitFixedTableColumnsToAvailableWidth(colPixel, fixedColumns, minWidths, fixedWidthTotal, tableInnerAvailableWidth); + + double remainingWidth = Math.Max(0, tableInnerAvailableWidth - fixedWidthTotal); if (totalWeight <= 0) { remainingWidth = 0; } DistributeFlexibleColumns(colPixel, colWeights, fixedColumns, minWidths, maxWidths, remainingWidth); - double tableWidth = Math.Min(tableAvailableWidth, colPixel.Sum()); - for (int c = 0; c < cols; c++) { - if (colPixel[c] - padLeft - padRight <= 0.001) { - throw new ArgumentException("Table horizontal cell padding must leave a positive text width."); - } - } + double tableWidth = Math.Min(tableAvailableWidth, colPixel.Sum() + (cols - 1) * columnGap); + ValidateTableCellTextWidths(tb2, style, cols, colPixel, columnGap); - var rowLines = new string[tb2.Rows.Count][][]; + var rowLines = new TableCellTextLayout[tb2.Rows.Count][]; var rowLineCounts = new int[tb2.Rows.Count]; var rowHeights = new double[tb2.Rows.Count]; var rowLeadings = new double[tb2.Rows.Count]; @@ -3418,29 +4659,31 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { rowSizes[ri] = rowSize; rowLeadings[ri] = rowLeading; rowBold[ri] = rowUsesBold; - rowLines[ri] = new string[cols][]; + rowLines[ri] = new TableCellTextLayout[cols]; int maxLines = 1; + double maxRequiredHeight = rowLeading + GetTableRowMaxPaddingTop(tb2, style, ri, cols) + GetTableRowMaxPaddingBottom(tb2, style, ri, cols); for (int ci = 0; ci < cols; ci++) { - rowLines[ri][ci] = System.Array.Empty(); + rowLines[ri][ci] = new TableCellTextLayout(new System.Collections.Generic.List> { new() }, new System.Collections.Generic.List { rowLeading }); } var cells = GetTableCellLayouts(tb2, ri, cols); for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { TableCellLayout cell = cells[cellIndex]; var cellFont = GetTableRowFont(currentOpts, rowUsesBold); - double cellWidth = GetTableCellWidth(colPixel, cell.Column, cell.ColumnSpan, 0D); - double innerWidth = Math.Max(1, cellWidth - padLeft - padRight); - var lines = WrapSimpleText(cell.Text, innerWidth, cellFont, rowSize); - rowLines[ri][cell.Column] = lines.ToArray(); + double cellWidth = GetTableCellWidth(colPixel, cell.Column, cell.ColumnSpan, columnGap); + double innerWidth = Math.Max(1, cellWidth - GetTableCellPaddingLeft(style, ri, cell.Column) - GetTableCellPaddingRight(style, ri, cell.Column)); + TableCellTextLayout lines = CreateTableCellTextLayout(cell, innerWidth, cellFont, rowSize, rowLeading); + rowLines[ri][cell.Column] = lines; if (cell.RowSpan <= 1) { - maxLines = Math.Max(maxLines, lines.Count); + maxLines = Math.Max(maxLines, lines.LineCount); + maxRequiredHeight = Math.Max(maxRequiredHeight, MeasureTableCellContentHeight(cell, lines, 0, lines.LineCount, rowLeading) + GetTableCellPaddingTop(style, ri, cell.Column) + GetTableCellPaddingBottom(style, ri, cell.Column)); } } rowLineCounts[ri] = maxLines; - rowHeights[ri] = Math.Max(maxLines * rowLeading + padTop + padBottom, style.MinRowHeight); + rowHeights[ri] = Math.Max(maxRequiredHeight, GetTableRowMinHeight(style, ri)); } - ApplyTableRowSpanHeights(tb2, cols, rowLines, rowHeights, rowLeadings, padTop, padBottom); + ApplyTableRowSpanHeights(tb2, style, cols, rowLines, rowHeights, rowLeadings, tableRowGap); System.Collections.Generic.List? captionLines = null; double captionLeading = 0; @@ -3458,7 +4701,7 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { } } - items.Add(new ColTable { Block = tb2, Style = style, Columns = cols, ColumnWidths = colPixel, RowLines = rowLines, RowLineCounts = rowLineCounts, RowHeights = rowHeights, RowLeadings = rowLeadings, RowSizes = rowSizes, RowBold = rowBold, Width = tableWidth, Size = size, HeaderRowCount = headerRowCount, FooterStartRowIndex = footerStartRowIndex, CaptionLines = captionLines, CaptionLeading = captionLeading, CaptionHeight = captionHeight }); + items.Add(new ColTable { Block = tb2, Style = style, Columns = cols, ColumnWidths = colPixel, RowLines = rowLines, RowLineCounts = rowLineCounts, RowHeights = rowHeights, RowLeadings = rowLeadings, RowSizes = rowSizes, RowBold = rowBold, Width = tableWidth, Size = size, HeaderRowCount = headerRowCount, RepeatHeaderRowCount = repeatHeaderRowCount, FooterStartRowIndex = footerStartRowIndex, CaptionLines = captionLines, CaptionLeading = captionLeading, CaptionHeight = captionHeight }); } else if (cb is HorizontalRuleBlock hr2) { items.Add(new ColRule { Block = hr2 }); } else if (cb is ImageBlock ib2) { @@ -3467,7 +4710,7 @@ void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { items.Add(new ColShape { Block = sb2 }); } else if (cb is DrawingBlock db2) { items.Add(new ColDrawing { Block = db2 }); - } else if (cb is TextFieldBlock || cb is CheckBoxBlock || cb is ChoiceFieldBlock) { + } else if (cb is TextFieldBlock || cb is CheckBoxBlock || cb is ChoiceFieldBlock || cb is RadioButtonGroupBlock) { items.Add(new ColForm { Block = cb }); } else if (cb is BookmarkBlock bookmark2) { items.Add(new ColBookmark { Block = bookmark2 }); @@ -3487,11 +4730,11 @@ double MeasureRowKeepTogetherHeight(System.Collections.Generic.List ite } else if (item is ColHead heading) { total += ResolveColumnSpacingBefore(heading.SpacingBefore, total) + heading.Lines.Count * heading.Leading + heading.SpacingAfter; } else if (item is ColListItem listItem) { - total += ResolveColumnSpacingBefore(listItem.SpacingBefore, total) + listItem.Lines.Count * listItem.Leading + listItem.SpacingAfter; + total += ResolveColumnSpacingBefore(listItem.SpacingBefore, total) + MeasureRichLinesHeight(listItem.Heights, listItem.Lines.Count, listItem.Leading) + listItem.SpacingAfter; } else if (item is ColPanel panel) { total += ResolveColumnSpacingBefore(panel.Style.SpacingBefore, total) + panel.Style.PaddingY + panel.Heights.Sum() + panel.Style.PaddingY + panel.Style.SpacingAfter; } else if (item is ColTable table) { - total += ResolveColumnSpacingBefore(table.Style.SpacingBefore, total) + table.CaptionHeight + table.RowHeights.Sum() + table.Style.SpacingAfter; + total += ResolveColumnSpacingBefore(table.Style.SpacingBefore, total) + table.CaptionHeight + GetTableRowsHeight(table.RowHeights, 0, table.RowHeights.Length, GetTableCellSpacing(table.Style)) + table.Style.SpacingAfter; } else if (item is ColRule rule) { PdfHorizontalRuleStyle ruleStyle = ResolveHorizontalRuleStyle(rule.Block, currentOpts); ValidateHorizontalRule(ruleStyle); @@ -3526,7 +4769,7 @@ double MeasureColItemFirstVisualHeight(ColItem item) { } if (item is ColListItem listItem) { - return listItem.SpacingBefore + (listItem.Lines.Count == 0 ? 0D : listItem.Leading); + return listItem.SpacingBefore + (listItem.Lines.Count == 0 ? 0D : GetRichLineHeight(listItem.Heights, 0, listItem.Leading)); } if (item is ColPanel panel) { @@ -3712,9 +4955,7 @@ bool AnyRemaining() { pageDirty = true; var paragraphFont = ChooseNormal(currentOpts.DefaultFont); WriteRichParagraph(sb, pblock, sliceLines, sliceHeights, currentOpts, FirstTextBaselineFromTop(paragraphFont, size, yCol), size, leading, currentPage!.Annotations, xCol + par.XOffset, par.TextWidth, start == 0 ? xCol + par.FirstLineXOffset : null, start == 0 ? par.FirstLineTextWidth : null); - if (pblock.Runs.Any(r => r.Bold)) { currentPage!.UsedBold = true; usedBold = true; } - if (pblock.Runs.Any(r => r.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } - if (pblock.Runs.Any(r => r.Bold && r.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + MarkRichFonts(pblock.Runs); yCol -= hsum; remain -= hsum; consumed += hsum; line += take; if (line >= lines.Count) { double space = spacingAfter; if (space <= remain) { yCol -= space; remain -= space; consumed += space; } idx++; line = 0; } } else if (it is ColHead ch) { @@ -3722,7 +4963,7 @@ bool AnyRemaining() { var lines = ch.Lines; double leading = ch.Leading; double size = ch.Size; - double spacingBefore = consumed > 0.001 ? ch.SpacingBefore : 0D; + double spacingBefore = (consumed > 0.001 || ch.ApplySpacingBeforeAtTop) ? ch.SpacingBefore : 0D; double needed = spacingBefore + lines.Count * leading + ch.SpacingAfter; if (ch.KeepWithNext && idx + 1 < items.Count) { double nextHeight = MeasureColItemFirstVisualHeight(items[idx + 1]); @@ -3746,12 +4987,14 @@ bool AnyRemaining() { if (currentOpts.CreateOutlineFromHeadings) { currentPage!.Bookmarks.Add(new PageBookmark { Level = hb2.Level, Title = hb2.Text, Y = yCol }); } - var headingFont = ChooseBold(ChooseNormal(currentOpts.DefaultFont)); + var headingFont = ch.Bold ? ChooseBold(ChooseNormal(currentOpts.DefaultFont)) : ChooseNormal(currentOpts.DefaultFont); double firstBaseline = FirstTextBaselineFromTop(headingFont, size, yCol); AddHeadingLinkAnnotations(hb2, lines, headingFont, size, leading, xCol, wCol, firstBaseline); - WriteLinesInternal("F2", size, leading, xCol, wCol, firstBaseline, lines, hb2.Align, ch.Color, applyBaselineTweak: false); - currentPage!.UsedBold = true; - usedBold = true; + WriteLinesInternal(ch.Bold ? "F2" : "F1", size, leading, xCol, wCol, firstBaseline, lines, hb2.Align, ch.Color, applyBaselineTweak: false); + if (ch.Bold) { + currentPage!.UsedBold = true; + usedBold = true; + } double consumedHeight = lines.Count * leading + ch.SpacingAfter; yCol -= consumedHeight; remain -= consumedHeight; consumed += consumedHeight; idx++; } else if (it is ColListItem listItem) { @@ -3799,26 +5042,34 @@ bool AnyRemaining() { int take = 0; double hsum = 0; for (int li2 = start; li2 < lines.Count; li2++) { - if (hsum + leading > availableForLines) break; - hsum += leading; + double lineHeight = GetRichLineHeight(listItem.Heights, li2, leading); + if (hsum + lineHeight > availableForLines) break; + hsum += lineHeight; take++; } if (take == 0) break; - var sliceLines = new System.Collections.Generic.List(take); + var sliceLines = new System.Collections.Generic.List>(take); + var sliceHeights = new System.Collections.Generic.List(take); for (int k = 0; k < take; k++) { sliceLines.Add(lines[start + k]); + sliceHeights.Add(GetRichLineHeight(listItem.Heights, start + k, leading)); } pageDirty = true; var listFont = ChooseNormal(currentOpts.DefaultFont); double baselineY = FirstTextBaselineFromTop(listFont, listItem.Size, yCol); if (line == 0) { + if (!string.IsNullOrEmpty(listItem.BookmarkName)) { + AddNamedDestinationName(listItem.BookmarkName!, yCol); + } + var markerLines = new System.Collections.Generic.List(1) { listItem.Marker }; WriteLinesInternal("F1", listItem.Size, leading, xCol + listItem.MarkerXOffset, listItem.MarkerWidth, baselineY, markerLines, listItem.MarkerAlign, listItem.Color, applyBaselineTweak: true); } - WriteLinesInternal("F1", listItem.Size, leading, xCol + listItem.TextXOffset, listItem.TextWidth, baselineY, sliceLines, listItem.TextAlign, listItem.Color, applyBaselineTweak: true); + WriteRichParagraph(sb, new RichParagraphBlock(listItem.Runs, listItem.TextAlign, listItem.Color), sliceLines, sliceHeights, currentOpts, baselineY, listItem.Size, leading, currentPage!.Annotations, xCol + listItem.TextXOffset, listItem.TextWidth); + MarkRichFonts(listItem.Runs); yCol -= hsum; remain -= hsum; consumed += hsum; @@ -3875,12 +5126,10 @@ bool AnyRemaining() { double panelTop = yCol; double panelBottom = yCol - panelHeight; if (panelStyle.Background.HasValue) { pageDirty = true; DrawRowFill(sb, panelStyle.Background.Value, xPanel, panelBottom, panel.PanelWidth, panelTop - panelBottom); } - if (panelStyle.BorderColor.HasValue && panelStyle.BorderWidth > 0) { pageDirty = true; DrawRowRect(sb, panelStyle.BorderColor.Value, panelStyle.BorderWidth, xPanel, panelBottom, panel.PanelWidth, panelTop - panelBottom); } + if (DrawPanelBorder(sb, panelStyle, xPanel, panelBottom, panel.PanelWidth, panelTop - panelBottom)) { pageDirty = true; } pageDirty = true; WriteRichParagraph(sb, new RichParagraphBlock(pblock.Runs, pblock.Align, pblock.DefaultColor), lines, heights, currentOpts, panelTop - panelStyle.PaddingY - panel.FirstBaselineOffset, panel.Size, panel.Leading, currentPage!.Annotations, xPanel + panelStyle.PaddingX, panel.TextWidth); - if (pblock.Runs.Any(r => r.Bold)) { currentPage!.UsedBold = true; usedBold = true; } - if (pblock.Runs.Any(r => r.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } - if (pblock.Runs.Any(r => r.Bold && r.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + MarkRichFonts(pblock.Runs); yCol = panelBottom; remain -= panelHeight; @@ -3923,7 +5172,7 @@ bool AnyRemaining() { double usedBottomPad = lastSeg ? panelStyle.PaddingY : Math.Max(0, remain - (topPad + hsum)); double panelBottom = yCol - (topPad + hsum + usedBottomPad); if (panelStyle.Background.HasValue) { pageDirty = true; DrawRowFill(sb, panelStyle.Background.Value, xPanel, panelBottom, panel.PanelWidth, panelTop - panelBottom); } - if (panelStyle.BorderColor.HasValue && panelStyle.BorderWidth > 0) { pageDirty = true; DrawRowRect(sb, panelStyle.BorderColor.Value, panelStyle.BorderWidth, xPanel, panelBottom, panel.PanelWidth, panelTop - panelBottom); } + if (DrawPanelBorder(sb, panelStyle, xPanel, panelBottom, panel.PanelWidth, panelTop - panelBottom)) { pageDirty = true; } var sliceLines = new System.Collections.Generic.List>(); var sliceHeights = new System.Collections.Generic.List(); @@ -3934,9 +5183,7 @@ bool AnyRemaining() { pageDirty = true; WriteRichParagraph(sb, new RichParagraphBlock(pblock.Runs, pblock.Align, pblock.DefaultColor), sliceLines, sliceHeights, currentOpts, panelTop - topPad - panel.FirstBaselineOffset, panel.Size, panel.Leading, currentPage!.Annotations, xPanel + panelStyle.PaddingX, panel.TextWidth); - if (pblock.Runs.Any(r => r.Bold)) { currentPage!.UsedBold = true; usedBold = true; } - if (pblock.Runs.Any(r => r.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } - if (pblock.Runs.Any(r => r.Bold && r.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + MarkRichFonts(pblock.Runs); double segmentHeight = panelTop - panelBottom; yCol = panelBottom; @@ -3962,12 +5209,14 @@ bool AnyRemaining() { double padRight = GetTableCellPaddingRight(tableStyle); double padTop = GetTableCellPaddingTop(tableStyle); double padBottom = GetTableCellPaddingBottom(tableStyle); + double columnGap = GetTableCellSpacing(tableStyle); + double columnTableRowGap = columnGap; double xTable = ResolveTableX(tbColumn.Align, tableStyle, xCol, wCol, table.Width); double maxContentHeight = currentOpts.PageHeight - currentOpts.MarginTop - currentOpts.MarginBottom; double tableSpacingBefore = line == 0 && consumed > 0.001 ? tableStyle.SpacingBefore : 0D; if (line == 0 && tableStyle.KeepTogether) { - double keepHeight = tableSpacingBefore + table.CaptionHeight + table.RowHeights.Sum() + tableStyle.SpacingAfter; + double keepHeight = tableSpacingBefore + table.CaptionHeight + GetTableRowsHeight(table.RowHeights, 0, table.RowHeights.Length, columnTableRowGap) + tableStyle.SpacingAfter; if (keepHeight > maxContentHeight + 0.001) { throw new ArgumentException("Table height exceeds the available page content height."); } @@ -3980,7 +5229,7 @@ bool AnyRemaining() { } if (line == 0 && tableStyle.KeepWithNext && idx + 1 < items.Count) { - double tableHeight = tableSpacingBefore + table.CaptionHeight + table.RowHeights.Sum() + tableStyle.SpacingAfter; + double tableHeight = tableSpacingBefore + table.CaptionHeight + GetTableRowsHeight(table.RowHeights, 0, table.RowHeights.Length, columnTableRowGap) + tableStyle.SpacingAfter; double nextHeight = MeasureColItemFirstVisualHeight(items[idx + 1]); double keepHeight = tableHeight + nextHeight; if (nextHeight > 0.001 && tableHeight <= maxContentHeight + 0.001 && keepHeight <= maxContentHeight + 0.001 && keepHeight > remain + 0.001) { @@ -4017,12 +5266,12 @@ bool AnyRemaining() { } double repeatHeaderHeight = 0; - for (int headerIndex = 0; headerIndex < table.HeaderRowCount; headerIndex++) { - repeatHeaderHeight += table.RowHeights[headerIndex]; + for (int headerIndex = 0; headerIndex < table.RepeatHeaderRowCount; headerIndex++) { + repeatHeaderHeight += table.RowHeights[headerIndex] + GetTableRowGapAfter(headerIndex, tbColumn.Rows.Count, columnTableRowGap); } bool HasRepeatableHeader() => - table.HeaderRowCount > 0 && + table.RepeatHeaderRowCount > 0 && tbColumn.Rows.Count > table.HeaderRowCount; bool AtContinuationPageTop() => @@ -4034,7 +5283,9 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, double rowSize = table.RowSizes[rowIndex]; double rowLeading = table.RowLeadings[rowIndex]; bool wholeRowSegment = startLine == 0 && lineCount == table.RowLineCounts[rowIndex]; - double rowHeight = wholeRowSegment ? table.RowHeights[rowIndex] : Math.Max(1, lineCount) * rowLeading + padTop + padBottom; + double rowPadTop = GetTableRowMaxPaddingTop(tbColumn, tableStyle, rowIndex, table.Columns); + double rowPadBottom = GetTableRowMaxPaddingBottom(tbColumn, tableStyle, rowIndex, table.Columns); + double rowHeight = wholeRowSegment ? table.RowHeights[rowIndex] : Math.Max(1, lineCount) * rowLeading + rowPadTop + rowPadBottom; if (rowUsesBold) { currentPage!.UsedBold = true; usedBold = true; @@ -4045,9 +5296,9 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int bodyRowIndex = rowIndex - table.HeaderRowCount; bool stripeBodyRow = bodyRowIndex >= 0 && bodyRowIndex % 2 == 1; bool[] rowFillSkips = GetRowSpanContinuationSkipColumns(tbColumn, rowIndex, table.Columns); - if (tableStyle.HeaderFill is not null && renderAsHeader) { pageDirty = true; DrawTableRowFill(sb, tableStyle.HeaderFill.Value, xTable, table.ColumnWidths, 0D, rowBottom, rowHeight, rowFillSkips); } - else if (tableStyle.FooterFill is not null && renderAsFooter) { pageDirty = true; DrawTableRowFill(sb, tableStyle.FooterFill.Value, xTable, table.ColumnWidths, 0D, rowBottom, rowHeight, rowFillSkips); } - else if (!renderAsHeader && !renderAsFooter && tableStyle.RowStripeFill is not null && stripeBodyRow) { pageDirty = true; DrawTableRowFill(sb, tableStyle.RowStripeFill.Value, xTable, table.ColumnWidths, 0D, rowBottom, rowHeight, rowFillSkips); } + if (tableStyle.HeaderFill is not null && renderAsHeader) { pageDirty = true; DrawTableRowFill(sb, tableStyle.HeaderFill.Value, xTable, table.ColumnWidths, columnGap, rowBottom, rowHeight, rowFillSkips); } + else if (tableStyle.FooterFill is not null && renderAsFooter) { pageDirty = true; DrawTableRowFill(sb, tableStyle.FooterFill.Value, xTable, table.ColumnWidths, columnGap, rowBottom, rowHeight, rowFillSkips); } + else if (!renderAsHeader && !renderAsFooter && tableStyle.RowStripeFill is not null && stripeBodyRow) { pageDirty = true; DrawTableRowFill(sb, tableStyle.RowStripeFill.Value, xTable, table.ColumnWidths, columnGap, rowBottom, rowHeight, rowFillSkips); } if (!renderAsHeader && !renderAsFooter && tableStyle.BodyColumnFills != null) { bool[] bodyColumnFillSkips = GetMergedCellContinuationSkipColumns(tbColumn, rowIndex, table.Columns); @@ -4058,7 +5309,7 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, pageDirty = true; DrawRowFill(sb, fill.Value, fillX, rowBottom, table.ColumnWidths[fillColumn], rowHeight); } - fillX += table.ColumnWidths[fillColumn]; + fillX += table.ColumnWidths[fillColumn] + columnGap; } } @@ -4073,19 +5324,24 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, double fillBottom = rowBottom; if (wholeRowSegment) { if (fillCell.RowSpan > 1) { - fillHeight = GetTableCellHeight(table.RowHeights, rowIndex, fillCell.RowSpan); + fillHeight = GetTableCellHeight(table.RowHeights, rowIndex, fillCell.RowSpan, columnTableRowGap); fillBottom = yCol - fillHeight; } } pageDirty = true; - DrawRowFill(sb, fill, fillX, fillBottom, GetTableCellWidth(table.ColumnWidths, fillColumn, span, 0D), fillHeight); + DrawRowFill(sb, fill, fillX, fillBottom, GetTableCellWidth(table.ColumnWidths, fillColumn, span, columnGap), fillHeight); } - fillX += table.ColumnWidths[fillColumn]; + fillX += table.ColumnWidths[fillColumn] + columnGap; } } + if (DrawTableCellDataBars(sb, tableStyle, cells, rowIndex, table.Columns, xTable, yCol, rowBottom, rowHeight, table.ColumnWidths, columnGap, table.RowHeights, columnTableRowGap, wholeRowSegment, startLine, rowFillSkips)) { + pageDirty = true; + } + if (DrawTableCellIcons(sb, tableStyle, cells, rowIndex, table.Columns, xTable, yCol, rowBottom, rowHeight, table.ColumnWidths, columnGap, table.RowHeights, columnTableRowGap, wholeRowSegment, startLine, rowFillSkips)) { + pageDirty = true; + } - string fontRes = GetTableRowFontResource(rowUsesBold); var textColor = renderAsHeader ? tableStyle.HeaderTextColor : renderAsFooter ? tableStyle.FooterTextColor : tableStyle.TextColor; double xi = xTable; for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { @@ -4093,59 +5349,72 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int c = cell.Column; xi = xTable; for (int xColumn = 0; xColumn < c; xColumn++) { - xi += table.ColumnWidths[xColumn]; + xi += table.ColumnWidths[xColumn] + columnGap; } - double cellWidth = GetTableCellWidth(table.ColumnWidths, c, cell.ColumnSpan, 0D); - double innerW = cellWidth - padLeft - padRight; - double cellHeight = wholeRowSegment && cell.RowSpan > 1 ? GetTableCellHeight(table.RowHeights, rowIndex, cell.RowSpan) : rowHeight; + double cellWidth = GetTableCellWidth(table.ColumnWidths, c, cell.ColumnSpan, columnGap); + double cellPadLeft = GetTableCellPaddingLeft(tableStyle, rowIndex, c); + double cellPadRight = GetTableCellPaddingRight(tableStyle, rowIndex, c); + double cellPadTop = GetTableCellPaddingTop(tableStyle, rowIndex, c); + double cellPadBottom = GetTableCellPaddingBottom(tableStyle, rowIndex, c); + double innerW = cellWidth - cellPadLeft - cellPadRight; + double cellHeight = wholeRowSegment && cell.RowSpan > 1 ? GetTableCellHeight(table.RowHeights, rowIndex, cell.RowSpan, columnTableRowGap) : rowHeight; double cellBottom = yCol - cellHeight; - var align = PdfColumnAlign.Left; - if (tableStyle.Alignments != null && c < tableStyle.Alignments.Count) align = tableStyle.Alignments[c]; - if (tableStyle.RightAlignNumeric && LooksNumeric(cell.Text)) align = PdfColumnAlign.Right; - var verticalAlign = PdfCellVerticalAlign.Top; - if (tableStyle.VerticalAlignments != null && c < tableStyle.VerticalAlignments.Count) verticalAlign = tableStyle.VerticalAlignments[c]; + PdfColumnAlign align = GetTableCellAlignment(tableStyle, rowIndex, c, cell.Text); + PdfCellVerticalAlign verticalAlign = GetTableCellVerticalAlignment(tableStyle, rowIndex, c); var cellFont = GetTableRowFont(currentOpts, rowUsesBold); - var lines = table.RowLines[rowIndex][c]; + TableCellTextLayout lines = table.RowLines[rowIndex][c]; int sourceStartLine = wholeRowSegment && cell.RowSpan > 1 ? 0 : startLine; - int requestedLineCount = wholeRowSegment && cell.RowSpan > 1 ? lines.Length : lineCount; - int visibleLineCount = Math.Max(0, Math.Min(requestedLineCount, lines.Length - sourceStartLine)); + int requestedLineCount = wholeRowSegment && cell.RowSpan > 1 ? lines.LineCount : lineCount; + int visibleLineCount = Math.Max(0, Math.Min(requestedLineCount, lines.LineCount - sourceStartLine)); double verticalOffset = 0; + double visibleTextHeight = 0D; if (visibleLineCount > 0) { - double availableTextHeight = Math.Max(0, cellHeight - padTop - padBottom); - double visibleTextHeight = visibleLineCount * rowLeading; - double unusedTextHeight = Math.Max(0, availableTextHeight - visibleTextHeight); + double availableTextHeight = Math.Max(0, cellHeight - cellPadTop - cellPadBottom); + visibleTextHeight = MeasureTableCellTextHeight(lines, sourceStartLine, visibleLineCount, rowLeading); + double visibleContentHeight = MeasureTableCellContentHeight(cell, lines, sourceStartLine, visibleLineCount, rowLeading); + double unusedTextHeight = Math.Max(0, availableTextHeight - visibleContentHeight); if (verticalAlign == PdfCellVerticalAlign.Middle) verticalOffset = unusedTextHeight / 2; else if (verticalAlign == PdfCellVerticalAlign.Bottom) verticalOffset = unusedTextHeight; } - double firstBaseline = yCol - padTop - verticalOffset - GetAscender(cellFont, rowSize) + tableStyle.RowBaselineOffset; + double firstBaseline = yCol - cellPadTop - verticalOffset - GetAscender(cellFont, rowSize) + tableStyle.RowBaselineOffset; pageDirty = true; - for (int lineIndex = 0; lineIndex < visibleLineCount; lineIndex++) { - string cellLine = lines[sourceStartLine + lineIndex]; - double textW = EstimateSimpleTextWidth(cellLine, cellFont, rowSize); - double offset = 0; - if (align == PdfColumnAlign.Center) offset = Math.Max(0, (innerW - textW) / 2); - else if (align == PdfColumnAlign.Right) offset = Math.Max(0, innerW - textW); - double xCell = xi + padLeft + offset; - double yCell = firstBaseline - lineIndex * rowLeading; - if (ShouldClipTableCellText(xCell, yCell, textW, cellFont, rowSize, xi, cellBottom, cellWidth, cellHeight)) { - WriteClippedCell(sb, fontRes, rowSize, xCell, yCell, cellLine, textColor, currentOpts, xi - TableCellClipBleed, cellBottom - TableCellClipBleed, cellWidth + TableCellClipBleed * 2D, cellHeight + TableCellClipBleed * 2D); - } else { - WriteCell(sb, fontRes, rowSize, xCell, yCell, cellLine, textColor, currentOpts); - } - } - + if (cell.Runs.Any(run => run.Bold || rowUsesBold)) { currentPage!.UsedBold = true; usedBold = true; } + if (cell.Runs.Any(run => run.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } + if (cell.Runs.Any(run => (run.Bold || rowUsesBold) && run.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } string? linkUri = cell.LinkUri; + string? linkDestinationName = cell.LinkDestinationName; string? linkContents = cell.LinkContents; if (tbColumn.Links.TryGetValue((rowIndex, c), out var uri)) { linkUri = uri; + linkDestinationName = null; linkContents = cell.Text; } - if (!string.IsNullOrEmpty(linkUri)) { - currentPage!.Annotations.Add(new LinkAnnotation { X1 = xi + padLeft, Y1 = cellBottom + padBottom, X2 = xi + padLeft + innerW, Y2 = yCol - padTop, Uri = linkUri!, Contents = linkContents ?? cell.Text }); + if (sourceStartLine == 0) { + AddTableCellNamedDestinationName(cell.NamedDestinationName, yCol); + } + + if (visibleLineCount > 0) { + var visibleLines = SliceTableCellLines(lines, sourceStartLine, visibleLineCount); + visibleLines = StripRichLineLinksWhenCellLinked(visibleLines, linkUri, linkDestinationName); + var visibleHeights = SliceTableCellLineHeights(lines, sourceStartLine, visibleLineCount, rowLeading); + var paragraph = new RichParagraphBlock(StripRunLinksWhenCellLinked(cell.Runs, linkUri, linkDestinationName), MapTableCellAlignment(align), textColor); + WriteClippedRichParagraph(sb, paragraph, visibleLines, visibleHeights, currentOpts, firstBaseline, rowSize, rowLeading, currentPage!.Annotations, xi - TableCellClipBleed, cellBottom - TableCellClipBleed, cellWidth + (TableCellClipBleed * 2D), cellHeight + (TableCellClipBleed * 2D), xi + cellPadLeft, innerW); + } + if ((cell.Images.Count > 0 || cell.CheckBoxes.Count > 0 || cell.FormFields.Count > 0) && sourceStartLine == 0) { + if (CanRenderTableCellCheckBoxInline(cell, lines, sourceStartLine, visibleLineCount)) { + RenderTableCellInlineCheckBox(currentPage!, cell, align, lines.Lines[sourceStartLine], xi + cellPadLeft, innerW, firstBaseline); + } else { + double formFieldTop = yCol - cellPadTop - verticalOffset - (string.IsNullOrEmpty(cell.Text) ? 0D : visibleTextHeight + TableCellCheckBoxGap); + RenderTableCellObjects(currentPage!, cell, align, xi + cellPadLeft, innerW, formFieldTop); + } + } + + if (HasCellLinkTarget(linkUri, linkDestinationName)) { + currentPage!.Annotations.Add(new LinkAnnotation { X1 = xi + cellPadLeft, Y1 = cellBottom + cellPadBottom, X2 = xi + cellPadLeft + innerW, Y2 = yCol - cellPadTop, Uri = linkUri, DestinationName = linkDestinationName, Contents = linkContents ?? cell.Text }); } } @@ -4155,8 +5424,8 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, bool[] bottomBorderSkips = GetRowSpanBoundarySkipColumns(tbColumn, rowIndex, table.Columns); bool segmentBorderRows = HasSkippedColumns(topBorderSkips, table.Columns) || HasSkippedColumns(bottomBorderSkips, table.Columns); if (segmentBorderRows) { - DrawTableHorizontalLine(sb, tableStyle.BorderColor.Value, tableStyle.BorderWidth, xTable, table.ColumnWidths, 0D, rowBottom + rowHeight, topBorderSkips); - DrawTableHorizontalLine(sb, tableStyle.BorderColor.Value, tableStyle.BorderWidth, xTable, table.ColumnWidths, 0D, rowBottom, bottomBorderSkips); + DrawTableHorizontalLine(sb, tableStyle.BorderColor.Value, tableStyle.BorderWidth, xTable, table.ColumnWidths, columnGap, rowBottom + rowHeight, topBorderSkips); + DrawTableHorizontalLine(sb, tableStyle.BorderColor.Value, tableStyle.BorderWidth, xTable, table.ColumnWidths, columnGap, rowBottom, bottomBorderSkips); DrawVLine(sb, tableStyle.BorderColor.Value, tableStyle.BorderWidth, xTable, rowBottom + rowHeight, rowBottom); DrawVLine(sb, tableStyle.BorderColor.Value, tableStyle.BorderWidth, xTable + table.Width, rowBottom + rowHeight, rowBottom); } else { @@ -4167,10 +5436,12 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, for (int c = 0; c < table.Columns - 1; c++) { xi2 += table.ColumnWidths[c]; if (IsTableBoundaryInsideSpannedCell(tbColumn, rowIndex, c, table.Columns)) { + xi2 += columnGap; continue; } DrawVLine(sb, tableStyle.BorderColor.Value, tableStyle.BorderWidth, xi2, rowBottom + rowHeight, rowBottom); + xi2 += columnGap; } } @@ -4179,7 +5450,7 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, double footerSeparatorWidth = tableStyle.FooterSeparatorWidth > 0 ? tableStyle.FooterSeparatorWidth : tableStyle.RowSeparatorWidth; if (footerSeparatorColor is not null && footerSeparatorWidth > 0) { pageDirty = true; - DrawTableHorizontalLine(sb, footerSeparatorColor.Value, footerSeparatorWidth, xTable, table.ColumnWidths, 0D, yCol, GetRowSpanBoundarySkipColumns(tbColumn, rowIndex - 1, table.Columns)); + DrawTableHorizontalLine(sb, footerSeparatorColor.Value, footerSeparatorWidth, xTable, table.ColumnWidths, columnGap, yCol, GetRowSpanBoundarySkipColumns(tbColumn, rowIndex - 1, table.Columns)); } } @@ -4187,7 +5458,7 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, double separatorWidth = renderAsHeader && tableStyle.HeaderSeparatorWidth > 0 ? tableStyle.HeaderSeparatorWidth : tableStyle.RowSeparatorWidth; if (separatorColor is not null && separatorWidth > 0) { pageDirty = true; - DrawTableHorizontalLine(sb, separatorColor.Value, separatorWidth, xTable, table.ColumnWidths, 0D, rowBottom, GetRowSpanBoundarySkipColumns(tbColumn, rowIndex, table.Columns)); + DrawTableHorizontalLine(sb, separatorColor.Value, separatorWidth, xTable, table.ColumnWidths, columnGap, rowBottom, GetRowSpanBoundarySkipColumns(tbColumn, rowIndex, table.Columns)); } if (tableStyle.CellBorders != null && tableStyle.CellBorders.Count > 0) { @@ -4196,29 +5467,28 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, if (tableStyle.CellBorders.TryGetValue((rowIndex, borderColumn), out PdfCellBorder? cellBorder) && TryGetTableCellLayoutAtColumn(cells, borderColumn, out TableCellLayout borderCell) && (borderColumn >= rowFillSkips.Length || !rowFillSkips[borderColumn]) && - cellBorder != null && - cellBorder.Color.HasValue && - cellBorder.Width > 0) { + HasRenderableCellBorder(cellBorder)) { int span = wholeRowSegment ? borderCell.ColumnSpan : 1; double borderHeight = rowHeight; double borderBottom = rowBottom; if (wholeRowSegment) { if (borderCell.RowSpan > 1) { - borderHeight = GetTableCellHeight(table.RowHeights, rowIndex, borderCell.RowSpan); + borderHeight = GetTableCellHeight(table.RowHeights, rowIndex, borderCell.RowSpan, columnTableRowGap); borderBottom = yCol - borderHeight; } } pageDirty = true; - DrawCellBorder(sb, cellBorder, borderX, borderBottom, GetTableCellWidth(table.ColumnWidths, borderColumn, span, 0D), borderHeight); + DrawCellBorder(sb, cellBorder, borderX, borderBottom, GetTableCellWidth(table.ColumnWidths, borderColumn, span, columnGap), borderHeight); } - borderX += table.ColumnWidths[borderColumn]; + borderX += table.ColumnWidths[borderColumn] + columnGap; } } - yCol -= rowHeight; - remain -= rowHeight; - consumed += rowHeight; + double rowAdvance = rowHeight + (wholeRowSegment ? GetTableRowGapAfter(rowIndex, tbColumn.Rows.Count, columnTableRowGap) : 0D); + yCol -= rowAdvance; + remain -= rowAdvance; + consumed += rowAdvance; } void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => @@ -4229,26 +5499,28 @@ void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => while (rowIndex < tbColumn.Rows.Count) { double rowHeight = table.RowHeights[rowIndex]; if (rowHeight > maxContentHeight + 0.001) { - if (!tableStyle.AllowRowBreakAcrossPages) { + if (!GetTableRowAllowBreakAcrossPages(tableStyle, rowIndex)) { throw new ArgumentException("Table row height exceeds the available page content height and row splitting is disabled."); } int totalLines = table.RowLineCounts[rowIndex]; + double rowPadTop = GetTableRowMaxPaddingTop(tbColumn, tableStyle, rowIndex, table.Columns); + double rowPadBottom = GetTableRowMaxPaddingBottom(tbColumn, tableStyle, rowIndex, table.Columns); bool repeatHeaderBeforeSegment = rowIndex >= table.HeaderRowCount && HasRepeatableHeader() && AtContinuationPageTop() && - repeatHeaderHeight + table.RowLeadings[rowIndex] + padTop + padBottom <= remain + 0.001; - double neededForFirstSegment = table.RowLeadings[rowIndex] + padTop + padBottom + (repeatHeaderBeforeSegment ? repeatHeaderHeight : 0); + repeatHeaderHeight + table.RowLeadings[rowIndex] + rowPadTop + rowPadBottom <= remain + 0.001; + double neededForFirstSegment = table.RowLeadings[rowIndex] + rowPadTop + rowPadBottom + (repeatHeaderBeforeSegment ? repeatHeaderHeight : 0); if (neededForFirstSegment > remain && consumed > 0) break; if (neededForFirstSegment > remain && consumed == 0) { remain = 0; break; } if (repeatHeaderBeforeSegment) { - for (int headerIndex = 0; headerIndex < table.HeaderRowCount; headerIndex++) { + for (int headerIndex = 0; headerIndex < table.RepeatHeaderRowCount; headerIndex++) { DrawColumnTableRow(headerIndex, renderAsHeader: true); } } - int maxLinesThisPage = Math.Max(1, (int)Math.Floor((remain - padTop - padBottom) / table.RowLeadings[rowIndex])); + int maxLinesThisPage = Math.Max(1, (int)Math.Floor((remain - rowPadTop - rowPadBottom) / table.RowLeadings[rowIndex])); int take = Math.Min(totalLines - rowStartLine, maxLinesThisPage); DrawColumnTableRowSegment(rowIndex, renderAsHeader: rowIndex < table.HeaderRowCount && rowStartLine == 0, rowStartLine, take); rowStartLine += take; @@ -4259,6 +5531,13 @@ void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => break; } + double gapAfterSplitRow = GetTableRowGapAfter(rowIndex, tbColumn.Rows.Count, columnTableRowGap); + if (gapAfterSplitRow > 0) { + yCol -= gapAfterSplitRow; + remain -= gapAfterSplitRow; + consumed += gapAfterSplitRow; + } + rowIndex++; line = rowIndex; subline = 0; @@ -4269,12 +5548,12 @@ void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => HasRepeatableHeader() && AtContinuationPageTop() && repeatHeaderHeight + rowHeight <= remain + 0.001; - double neededForNextRow = rowHeight + (repeatHeaderBeforeRow ? repeatHeaderHeight : 0); + double neededForNextRow = rowHeight + GetTableRowGapAfter(rowIndex, tbColumn.Rows.Count, columnTableRowGap) + (repeatHeaderBeforeRow ? repeatHeaderHeight : 0); if (neededForNextRow > remain && consumed > 0) break; if (neededForNextRow > remain && consumed == 0) { remain = 0; break; } if (repeatHeaderBeforeRow) { - for (int headerIndex = 0; headerIndex < table.HeaderRowCount; headerIndex++) { + for (int headerIndex = 0; headerIndex < table.RepeatHeaderRowCount; headerIndex++) { DrawColumnTableRow(headerIndex, renderAsHeader: true); } } @@ -4461,6 +5740,7 @@ void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => NewPage(); continue; } + DrawRowColumnSeparators(y, y - maxConsumed); y -= maxConsumed; } @@ -4549,12 +5829,10 @@ void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => double panelBottom = y - panelHeight; if (panelBottom < currentOpts.MarginBottom) { NewPage(); panelTop = y; panelBottom = y - panelHeight; } if (panelStyle.Background.HasValue) { pageDirty = true; DrawRowFill(sb, panelStyle.Background.Value, xLeft, panelBottom, panelWidth, panelTop - panelBottom); } - if (panelStyle.BorderColor.HasValue && panelStyle.BorderWidth > 0) { pageDirty = true; DrawRowRect(sb, panelStyle.BorderColor.Value, panelStyle.BorderWidth, xLeft, panelBottom, panelWidth, panelTop - panelBottom); } + if (DrawPanelBorder(sb, panelStyle, xLeft, panelBottom, panelWidth, panelTop - panelBottom)) { pageDirty = true; } pageDirty = true; WriteRichParagraph(sb, new RichParagraphBlock(ppb.Runs, ppb.Align, ppb.DefaultColor), lines, lineHeights, currentOpts, panelTop - panelStyle.PaddingY - firstBaselineOffset, size, leading, currentPage!.Annotations, xLeft + panelStyle.PaddingX, textWidthAvail); - if (ppb.Runs.Any(r => r.Bold)) { currentPage!.UsedBold = true; usedBold = true; } - if (ppb.Runs.Any(r => r.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } - if (ppb.Runs.Any(r => r.Bold && r.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + MarkRichFonts(ppb.Runs); y = panelBottom; if (panelStyle.SpacingAfter > 0) { if (y < yStart - 0.001 && y - panelStyle.SpacingAfter < currentOpts.MarginBottom) { @@ -4584,15 +5862,13 @@ void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => if (!lastSeg && topPad + hsum + usedBottomPad > avail) usedBottomPad = Math.Max(0, avail - (topPad + hsum)); double panelBottom = y - (topPad + hsum + usedBottomPad); if (panelStyle.Background.HasValue) { pageDirty = true; DrawRowFill(sb, panelStyle.Background.Value, xLeft, panelBottom, panelWidth, panelTop - panelBottom); } - if (panelStyle.BorderColor.HasValue && panelStyle.BorderWidth > 0) { pageDirty = true; DrawRowRect(sb, panelStyle.BorderColor.Value, panelStyle.BorderWidth, xLeft, panelBottom, panelWidth, panelTop - panelBottom); } + if (DrawPanelBorder(sb, panelStyle, xLeft, panelBottom, panelWidth, panelTop - panelBottom)) { pageDirty = true; } var sliceLines = new System.Collections.Generic.List>(); var sliceHeights = new System.Collections.Generic.List(); for (int k = 0; k < take; k++) { sliceLines.Add(lines[li + k]); sliceHeights.Add(lineHeights[li + k]); } pageDirty = true; WriteRichParagraph(sb, new RichParagraphBlock(ppb.Runs, ppb.Align, ppb.DefaultColor), sliceLines, sliceHeights, currentOpts, panelTop - topPad - firstBaselineOffset, size, leading, currentPage!.Annotations, xLeft + panelStyle.PaddingX, textWidthAvail); - if (ppb.Runs.Any(r => r.Bold)) { currentPage!.UsedBold = true; usedBold = true; } - if (ppb.Runs.Any(r => r.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } - if (ppb.Runs.Any(r => r.Bold && r.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + MarkRichFonts(ppb.Runs); y = panelBottom; li += take; firstSeg = false; if (li < lines.Count) { NewPage(); diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Objects.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Objects.cs index 84e98ca5a..4be3aff1a 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Objects.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Objects.cs @@ -60,6 +60,7 @@ public sealed class Page { public System.Collections.Generic.List Shadings { get; } = new(); public System.Collections.Generic.List Bookmarks { get; } = new(); public System.Collections.Generic.List NamedDestinations { get; } = new(); + public System.Collections.Generic.HashSet UsedFonts { get; } = new(); public bool UsedBold { get; set; } public bool UsedItalic { get; set; } public bool UsedBoldItalic { get; set; } @@ -89,6 +90,9 @@ private sealed class FormFieldAnnotation { public bool IsChecked { get; init; } public string CheckedValueName { get; init; } = "Yes"; public IReadOnlyList Options { get; init; } = Array.Empty(); + public double ButtonSize { get; init; } + public double ButtonGap { get; init; } + public PdfFormFieldStyle Style { get; init; } = new PdfFormFieldStyle(); public bool IsComboBox { get; init; } public bool AllowsMultipleSelection { get; init; } } @@ -96,7 +100,8 @@ private sealed class FormFieldAnnotation { private enum FormFieldAnnotationKind { Text, CheckBox, - Choice + Choice, + RadioButtonGroup } private sealed class PageBookmark { diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Text.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Text.cs index 0e7bf8a4e..ea23327c5 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Text.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Text.cs @@ -158,7 +158,7 @@ void AppendLongToken(string token, StringBuilder current, ref double currentWidt } // Rich paragraph layout - private sealed record RichSeg(string Text, bool Bold, bool Italic, bool Underline, bool Strike, PdfColor? Color, string? Uri, string? DestinationName, string? Contents, PdfStandardFont Font, PdfTextBaseline Baseline, bool LeadingSpace = false, double LeadingAdvance = 0, bool LeadingSpaceIsExpandable = true, PdfTabLeaderStyle LeadingTabLeader = PdfTabLeaderStyle.None, bool EndsWithHardBreak = false); + private sealed record RichSeg(string Text, bool Bold, bool Italic, bool Underline, bool Strike, PdfColor? Color, PdfColor? BackgroundColor, string? Uri, string? DestinationName, string? Contents, PdfStandardFont Font, double FontSize, PdfTextBaseline Baseline, bool LeadingSpace = false, double LeadingAdvance = 0, bool LeadingSpaceIsExpandable = true, PdfTabLeaderStyle LeadingTabLeader = PdfTabLeaderStyle.None, bool EndsWithHardBreak = false); private static double MeasureRichText(string text, PdfStandardFont font, double fontSize) => EstimateSimpleTextWidth(text, font, fontSize); @@ -175,6 +175,22 @@ private static double EffectiveRichFontSize(double fontSize, PdfTextBaseline bas private static double MeasureRichText(string text, PdfStandardFont font, double fontSize, PdfTextBaseline baseline) => EstimateSimpleTextWidth(text, font, EffectiveRichFontSize(fontSize, baseline)); + private static double MeasureRichLineWidth(System.Collections.Generic.IReadOnlyList line) { + double width = 0D; + for (int index = 0; index < line.Count; index++) { + RichSeg segment = line[index]; + if (segment.LeadingSpace) { + width += segment.LeadingAdvance > 0 + ? segment.LeadingAdvance + : MeasureRichText(" ", segment.Font, segment.FontSize, segment.Baseline); + } + + width += MeasureRichText(segment.Text, segment.Font, segment.FontSize, segment.Baseline); + } + + return width; + } + private static double CalculateDefaultTabAdvance(double lineWidth, double spaceWidth, double tabStopWidth = DefaultParagraphTabStopWidth) { if (lineWidth < 0 || double.IsNaN(lineWidth) || double.IsInfinity(lineWidth) || tabStopWidth <= 0 || double.IsNaN(tabStopWidth) || double.IsInfinity(tabStopWidth)) { @@ -235,7 +251,19 @@ private static (System.Collections.Generic.List 0 ? lineHeight / fontSize : 1.2D; + double currentLineHeight = lineHeight; double CurrentMaxWidth() => lines.Count == 1 ? firstLineWidthPts ?? maxWidthPts : maxWidthPts; + void RegisterLineHeight(double runFontSize) { + currentLineHeight = Math.Max(currentLineHeight, runFontSize * lineHeightRatio); + } + + void StartNewLine() { + heights.Add(currentLineHeight); + lines.Add(new()); + lineWidth = 0; + currentLineHeight = lineHeight; + } void MarkCurrentLineHardBreak() { var currentLine = lines[lines.Count - 1]; @@ -254,14 +282,17 @@ void MarkCurrentLineHardBreak() { bool strike = run.Strike; bool italic = run.Italic; var color = run.Color; + var backgroundColor = run.BackgroundColor; string? uri = run.LinkUri; string? destinationName = run.LinkDestinationName; string? contents = run.LinkContents; var baseline = run.Baseline; var tabLeader = run.TabLeader; var tabAlignment = run.TabAlignment; - var fontForRun = (bold && italic) ? ChooseBoldItalic(baseFont) : bold ? ChooseBold(baseFont) : italic ? ChooseItalic(baseFont) : baseFont; - double spaceW = MeasureRichText(" ", fontForRun, fontSize, baseline); + var runBaseFont = run.Font.HasValue ? ChooseNormal(run.Font.Value) : baseFont; + var fontForRun = (bold && italic) ? ChooseBoldItalic(runBaseFont) : bold ? ChooseBold(runBaseFont) : italic ? ChooseItalic(runBaseFont) : runBaseFont; + double runFontSize = run.FontSize ?? fontSize; + double spaceW = MeasureRichText(" ", fontForRun, runFontSize, baseline); int idx = 0; while (idx < text.Length) { int nextWs = text.IndexOfAny(TokenSplitChars, idx); @@ -272,13 +303,13 @@ void MarkCurrentLineHardBreak() { hadNewline = text[nextWs] == '\n'; idx = nextWs + 1; } - double tokenW = MeasureRichText(token, fontForRun, fontSize, baseline); + double tokenW = MeasureRichText(token, fontForRun, runFontSize, baseline); var lastLine = lines[lines.Count - 1]; double needed = lastLine.Count == 0 ? tokenW : pendingLeadingAdvance + tokenW; double currentMaxWidth = CurrentMaxWidth(); if (tokenW > currentMaxWidth) { - if (lastLine.Count > 0) { heights.Add(lineHeight); lines.Add(new()); lineWidth = 0; lastLine = lines[lines.Count - 1]; } + if (lastLine.Count > 0) { StartNewLine(); lastLine = lines[lines.Count - 1]; } pendingLeadingAdvance = 0; pendingLeadingIsExpandable = true; pendingLeadingIsTab = false; @@ -290,7 +321,7 @@ void MarkCurrentLineHardBreak() { double chunkW = 0; currentMaxWidth = CurrentMaxWidth(); while (pos + take < token.Length) { - double charW = MeasureRichText(token.Substring(pos + take, 1), fontForRun, fontSize, baseline); + double charW = MeasureRichText(token.Substring(pos + take, 1), fontForRun, runFontSize, baseline); if (take > 0 && chunkW + charW > currentMaxWidth) { break; } @@ -304,20 +335,19 @@ void MarkCurrentLineHardBreak() { if (take == 0) { take = 1; - chunkW = MeasureRichText(token.Substring(pos, 1), fontForRun, fontSize, baseline); + chunkW = MeasureRichText(token.Substring(pos, 1), fontForRun, runFontSize, baseline); } string chunk = token.Substring(pos, take); - lastLine.Add(new RichSeg(chunk, bold, italic, underline, strike, color, uri, destinationName, contents, fontForRun, baseline)); + lastLine.Add(new RichSeg(chunk, bold, italic, underline, strike, color, backgroundColor, uri, destinationName, contents, fontForRun, runFontSize, baseline)); + RegisterLineHeight(runFontSize); lineWidth += chunkW; pos += take; - if (pos < token.Length) { heights.Add(lineHeight); lines.Add(new()); lineWidth = 0; lastLine = lines[lines.Count - 1]; } + if (pos < token.Length) { StartNewLine(); lastLine = lines[lines.Count - 1]; } } if (hadNewline) { MarkCurrentLineHardBreak(); - heights.Add(lineHeight); - lines.Add(new()); - lineWidth = 0; + StartNewLine(); pendingLeadingAdvance = 0; pendingLeadingIsExpandable = true; pendingLeadingIsTab = false; @@ -334,22 +364,21 @@ void MarkCurrentLineHardBreak() { continue; } if (token.Length > 0 && pendingLeadingIsTab) { - pendingLeadingAdvance = CalculateTabAdvance(lineWidth, tokenW, spaceW, pendingLeadingTabAlignment, tabStopWidth, token, fontForRun, fontSize, baseline); + pendingLeadingAdvance = CalculateTabAdvance(lineWidth, tokenW, spaceW, pendingLeadingTabAlignment, tabStopWidth, token, fontForRun, runFontSize, baseline); } needed = lastLine.Count == 0 ? (pendingLeadingIsTab ? pendingLeadingAdvance + tokenW : tokenW) : pendingLeadingAdvance + tokenW; if (lineWidth + needed > currentMaxWidth && lastLine.Count > 0) { - heights.Add(lineHeight); - lines.Add(new()); - lineWidth = 0; + StartNewLine(); } if (token.Length > 0) { bool needsLeadingSpace = pendingLeadingAdvance > 0 && (lineWidth > 0 || pendingLeadingIsTab); double leadingAdvance = needsLeadingSpace ? pendingLeadingAdvance : 0; double segmentWidth = tokenW + leadingAdvance; var segmentLeader = needsLeadingSpace ? pendingLeadingTabLeader : PdfTabLeaderStyle.None; - lines[lines.Count - 1].Add(new RichSeg(token, bold, italic, underline, strike, color, uri, destinationName, contents, fontForRun, baseline, needsLeadingSpace, leadingAdvance, pendingLeadingIsExpandable, segmentLeader)); + lines[lines.Count - 1].Add(new RichSeg(token, bold, italic, underline, strike, color, backgroundColor, uri, destinationName, contents, fontForRun, runFontSize, baseline, needsLeadingSpace, leadingAdvance, pendingLeadingIsExpandable, segmentLeader)); + RegisterLineHeight(runFontSize); lineWidth += segmentWidth; pendingLeadingAdvance = 0; pendingLeadingIsExpandable = true; @@ -359,9 +388,7 @@ void MarkCurrentLineHardBreak() { } if (hadNewline) { MarkCurrentLineHardBreak(); - heights.Add(lineHeight); - lines.Add(new()); - lineWidth = 0; + StartNewLine(); pendingLeadingAdvance = 0; pendingLeadingIsExpandable = true; pendingLeadingIsTab = false; @@ -378,7 +405,7 @@ void MarkCurrentLineHardBreak() { } } if (lines.Count > 0 && lines[lines.Count - 1].Count == 0) { lines.RemoveAt(lines.Count - 1); } - if (heights.Count < lines.Count) heights.Add(lineHeight); + if (heights.Count < lines.Count) heights.Add(currentLineHeight); return (lines, heights); } @@ -387,13 +414,77 @@ private static void WriteRichParagraph(StringBuilder sb, RichParagraphBlock bloc double widthUsed = widthOverride ?? widthContent; var underlines = new System.Collections.Generic.List<(double X1, double X2, double Y, PdfColor Color)>(); var strikes = new System.Collections.Generic.List<(double X1, double X2, double Y, PdfColor Color)>(); + var backgrounds = new System.Collections.Generic.List<(double X, double Y, double Width, double Height, PdfColor Color)>(); + + double backgroundYOffset = 0D; + double xOrigin = xOverride ?? opts.MarginLeft; + for (int li = 0; li < lines.Count; li++) { + double lineY = startY - backgroundYOffset; + double lineWidthUsed = li == 0 ? firstLineWidthOverride ?? widthUsed : widthUsed; + double lineXOrigin = li == 0 ? firstLineXOverride ?? xOrigin : xOrigin; + var segs = lines[li]; + double baseLineW = 0; + int gapsCount = 0; + foreach (var seg in segs) { + double w = MeasureRichText(seg.Text, seg.Font, seg.FontSize, seg.Baseline); + if (seg.LeadingSpace) { + w += seg.LeadingAdvance > 0 ? seg.LeadingAdvance : MeasureRichText(" ", seg.Font, seg.FontSize, seg.Baseline); + if (seg.LeadingSpaceIsExpandable) { + gapsCount++; + } + } + + baseLineW += w; + } + + bool lineEndsWithHardBreak = segs.Any(seg => seg.EndsWithHardBreak); + bool justify = block.Align == PdfAlign.Justify && !lineEndsWithHardBreak && li != lines.Count - 1 && gapsCount > 0 && lineWidthUsed > baseLineW; + double wordSpacing = justify ? (lineWidthUsed - baseLineW) / gapsCount : 0; + double lineWForAlign = justify ? lineWidthUsed : baseLineW; + double dx = 0; + if (block.Align == PdfAlign.Center) dx = Math.Max(0, (lineWidthUsed - lineWForAlign) / 2); + else if (block.Align == PdfAlign.Right) dx = Math.Max(0, lineWidthUsed - lineWForAlign); + + double xCursor = dx; + foreach (var s in segs) { + if (s.LeadingSpace) { + double baseGap = s.LeadingAdvance > 0 ? s.LeadingAdvance : MeasureRichText(" ", s.Font, s.FontSize, s.Baseline); + xCursor += baseGap + (s.LeadingSpaceIsExpandable ? wordSpacing : 0); + } + + double wSeg = MeasureRichText(s.Text, s.Font, s.FontSize, s.Baseline); + if (s.BackgroundColor.HasValue && wSeg > 0) { + double runFontSize = EffectiveRichFontSize(s.FontSize, s.Baseline); + double textRise = TextRiseForBaseline(s.FontSize, s.Baseline); + double asc = GetAscender(s.Font, runFontSize); + double desc = GetDescender(s.Font, runFontSize); + double pad = Math.Max(1D, runFontSize * 0.08D); + double baselineY = lineY + textRise; + backgrounds.Add((lineXOrigin + xCursor, baselineY - desc - pad, wSeg, asc + desc + (pad * 2), s.BackgroundColor.Value)); + } + + xCursor += wSeg; + } + + backgroundYOffset += li < lineHeights.Count ? lineHeights[li] : defaultLeading; + } + + foreach (var bg in backgrounds) { + new ContentStreamBuilder(sb) + .SaveState() + .FillColor(bg.Color) + .Rectangle(bg.X, bg.Y, bg.Width, bg.Height) + .FillPath() + .RestoreState(); + } var content = new ContentStreamBuilder(sb) .BeginText() .TextLeading(defaultLeading); - double xOrigin = xOverride ?? opts.MarginLeft; + double yOffset = 0D; for (int li = 0; li < lines.Count; li++) { + double lineY = startY - yOffset; double lineWidthUsed = li == 0 ? firstLineWidthOverride ?? widthUsed : widthUsed; double lineXOrigin = li == 0 ? firstLineXOverride ?? xOrigin : xOrigin; var segs = lines[li]; @@ -403,9 +494,9 @@ private static void WriteRichParagraph(StringBuilder sb, RichParagraphBlock bloc int gapsCount = 0; for (int si = 0; si < segCount; si++) { var seg = segs[si]; - double w = MeasureRichText(seg.Text, seg.Font, fontSize, seg.Baseline); + double w = MeasureRichText(seg.Text, seg.Font, seg.FontSize, seg.Baseline); if (seg.LeadingSpace) { - w += seg.LeadingAdvance > 0 ? seg.LeadingAdvance : MeasureRichText(" ", seg.Font, fontSize, seg.Baseline); + w += seg.LeadingAdvance > 0 ? seg.LeadingAdvance : MeasureRichText(" ", seg.Font, seg.FontSize, seg.Baseline); if (seg.LeadingSpaceIsExpandable) { gapsCount++; } @@ -422,17 +513,16 @@ private static void WriteRichParagraph(StringBuilder sb, RichParagraphBlock bloc if (block.Align == PdfAlign.Center) dx = Math.Max(0, (lineWidthUsed - lineWForAlign) / 2); else if (block.Align == PdfAlign.Right) dx = Math.Max(0, lineWidthUsed - lineWForAlign); content - .TextMatrix(lineXOrigin + dx, startY - li * defaultLeading) + .TextMatrix(lineXOrigin + dx, lineY) .WordSpacing(wordSpacing); double xCursor = dx; double currentTextRise = 0; for (int si = 0; si < segs.Count; si++) { var s = segs[si]; - double lineY = startY - li * defaultLeading; - string fontRes = (s.Bold && s.Italic) ? "F4" : s.Bold ? "F2" : s.Italic ? "F3" : "F1"; - double runFontSize = EffectiveRichFontSize(fontSize, s.Baseline); - double textRise = TextRiseForBaseline(fontSize, s.Baseline); + string fontRes = GetStandardFontResourceName(s.Font, ChooseNormal(opts.DefaultFont)); + double runFontSize = EffectiveRichFontSize(s.FontSize, s.Baseline); + double textRise = TextRiseForBaseline(s.FontSize, s.Baseline); content.Font(fontRes, runFontSize); if (Math.Abs(textRise - currentTextRise) > 0.0001) { content.TextRise(textRise); @@ -440,13 +530,13 @@ private static void WriteRichParagraph(StringBuilder sb, RichParagraphBlock bloc } var color = s.Color ?? block.DefaultColor ?? opts.DefaultTextColor; - if (color.HasValue) content.FillColor(color.Value); + content.FillColor(color ?? PdfColor.Black); if (s.LeadingSpace) { - double baseGap = s.LeadingAdvance > 0 ? s.LeadingAdvance : MeasureRichText(" ", s.Font, fontSize, s.Baseline); + double baseGap = s.LeadingAdvance > 0 ? s.LeadingAdvance : MeasureRichText(" ", s.Font, s.FontSize, s.Baseline); double gap = baseGap + (s.LeadingSpaceIsExpandable ? wordSpacing : 0); if (s.LeadingTabLeader != PdfTabLeaderStyle.None) { - string leader = BuildTabLeaderText(gap, s.Font, fontSize, s.Baseline, s.LeadingTabLeader); + string leader = BuildTabLeaderText(gap, s.Font, s.FontSize, s.Baseline, s.LeadingTabLeader); if (leader.Length > 0) { content .TextMatrix(lineXOrigin + xCursor, lineY) @@ -467,7 +557,7 @@ private static void WriteRichParagraph(StringBuilder sb, RichParagraphBlock bloc } double segmentStartX = xCursor; content.ShowHexText(EncodeWinAnsiHex(s.Text)); - double wSeg = MeasureRichText(s.Text, s.Font, fontSize, s.Baseline); + double wSeg = MeasureRichText(s.Text, s.Font, s.FontSize, s.Baseline); double baselineY = lineY + textRise; if (s.Underline) { @@ -495,6 +585,8 @@ private static void WriteRichParagraph(StringBuilder sb, RichParagraphBlock bloc if (Math.Abs(currentTextRise) > 0.0001) { content.TextRise(0); } + + yOffset += li < lineHeights.Count ? lineHeights[li] : defaultLeading; } content .WordSpacing(0) @@ -522,6 +614,19 @@ private static void WriteRichParagraph(StringBuilder sb, RichParagraphBlock bloc } } + private static void WriteClippedRichParagraph(StringBuilder sb, RichParagraphBlock block, System.Collections.Generic.List> lines, System.Collections.Generic.List lineHeights, PdfOptions opts, double startY, double fontSize, double defaultLeading, System.Collections.Generic.List annots, double clipX, double clipY, double clipWidth, double clipHeight, double? xOverride = null, double? widthOverride = null, double? firstLineXOverride = null, double? firstLineWidthOverride = null) { + new ContentStreamBuilder(sb) + .SaveState() + .Rectangle(clipX, clipY, clipWidth, clipHeight) + .ClipPath() + .EndPath(); + + WriteRichParagraph(sb, block, lines, lineHeights, opts, startY, fontSize, defaultLeading, annots, xOverride, widthOverride, firstLineXOverride, firstLineWidthOverride); + + new ContentStreamBuilder(sb) + .RestoreState(); + } + private static string BuildTabLeaderText(double gap, PdfStandardFont font, double fontSize, PdfTextBaseline baseline, PdfTabLeaderStyle leaderStyle) { string leaderGlyph = leaderStyle switch { PdfTabLeaderStyle.Dots => ".", diff --git a/OfficeIMO.Tests/Excel.ClosedXmlGapFeatures.cs b/OfficeIMO.Tests/Excel.ClosedXmlGapFeatures.cs index 04a65b66d..58628c5f5 100644 --- a/OfficeIMO.Tests/Excel.ClosedXmlGapFeatures.cs +++ b/OfficeIMO.Tests/Excel.ClosedXmlGapFeatures.cs @@ -2240,9 +2240,9 @@ public void Test_ClosedXmlGap_ConditionalFormattingAndDataValidation_Management( IReadOnlyList rules = sheet.GetConditionalFormattingRules("A2:A4"); Assert.Equal(3, rules.Count); - Assert.Contains(rules, rule => rule.Type == ConditionalFormatValues.Expression.ToString() && rule.StopIfTrue); - Assert.Contains(rules, rule => rule.Type == ConditionalFormatValues.DuplicateValues.ToString()); - Assert.Contains(rules, rule => rule.Type == ConditionalFormatValues.Top10.ToString()); + Assert.Contains(rules, rule => rule.Type == "Expression" && rule.StopIfTrue); + Assert.Contains(rules, rule => rule.Type == "DuplicateValues"); + Assert.Contains(rules, rule => rule.Type == "Top10"); sheet.ValidationWholeNumber("B2:B4", DataValidationOperatorValues.Between, 1, 30); sheet.SetDataValidationMessages("B2:B4", new ExcelDataValidationMessageOptions { diff --git a/OfficeIMO.Tests/Excel.ConditionalFormatting.cs b/OfficeIMO.Tests/Excel.ConditionalFormatting.cs index f5452d41f..a27b1165e 100644 --- a/OfficeIMO.Tests/Excel.ConditionalFormatting.cs +++ b/OfficeIMO.Tests/Excel.ConditionalFormatting.cs @@ -33,6 +33,9 @@ public void Test_AddConditionalRule() { } using (var document = ExcelDocument.Load(filePath, readOnly: true)) { + ExcelConditionalFormattingInfo info = Assert.Single(document.Sheets[0].GetConditionalFormattingRules("A1:A3")); + Assert.Equal("DataBar", info.Type); + Assert.Equal("FF0000FF", info.DataBarColor); Assert.Empty(document.ValidateOpenXml()); } } @@ -65,6 +68,10 @@ public void Test_RangeFluentConditionalFormatting() { } using (var document = ExcelDocument.Load(filePath, readOnly: true)) { + var sheet = document.Sheets[0]; + ExcelConditionalFormattingInfo info = Assert.Single(sheet.GetConditionalFormattingRules("A1:A3")); + Assert.Equal("ColorScale", info.Type); + Assert.Equal(new[] { "FFFF0000", "FF00FF00" }, info.ColorScaleColors); Assert.Empty(document.ValidateOpenXml()); } } @@ -130,6 +137,42 @@ public void Test_AddConditionalDataBar() { } } + [Fact] + public void Test_AddConditionalIconSet() { + string filePath = Path.Combine(_directoryWithFiles, "ConditionalIconSet.xlsx"); + using (var document = ExcelDocument.Create(filePath)) { + var sheet = document.AddWorkSheet("Data"); + sheet.CellValue(1, 1, 1d); + sheet.CellValue(2, 1, 2d); + sheet.CellValue(3, 1, 3d); + sheet.AddConditionalIconSet("A1:A3", IconSetValues.ThreeTrafficLights1, showValue: false, reverseIconOrder: true); + document.Save(); + } + + using (SpreadsheetDocument spreadsheet = SpreadsheetDocument.Open(filePath, false)) { + var workbookPart = spreadsheet.WorkbookPart!; + WorksheetPart wsPart = workbookPart.WorksheetParts.First(); + ConditionalFormatting? cf = wsPart.Worksheet.Elements().FirstOrDefault(); + Assert.NotNull(cf); + ConditionalFormattingRule rule = cf!.Elements().First(); + Assert.Equal(ConditionalFormatValues.IconSet, rule.Type!.Value); + IconSet? iconSet = rule.GetFirstChild(); + Assert.NotNull(iconSet); + Assert.Equal(IconSetValues.ThreeTrafficLights1, iconSet!.IconSetValue!.Value); + Assert.False(iconSet.ShowValue!.Value); + Assert.True(iconSet.Reverse!.Value); + } + + using (var document = ExcelDocument.Load(filePath, readOnly: true)) { + ExcelConditionalFormattingInfo info = Assert.Single(document.Sheets[0].GetConditionalFormattingRules("A1:A3")); + Assert.Equal("IconSet", info.Type); + Assert.Equal("ThreeTrafficLights1", info.IconSet); + Assert.False(info.IconSetShowValue); + Assert.True(info.IconSetReverse); + Assert.Empty(document.ValidateOpenXml()); + } + } + [Fact] public async Task Test_ConditionalFormattingConcurrent() { string filePath = Path.Combine(_directoryWithFiles, "ConditionalConcurrent.xlsx"); diff --git a/OfficeIMO.Tests/OfficeIMO.Tests.csproj b/OfficeIMO.Tests/OfficeIMO.Tests.csproj index 7a828a475..deb974937 100644 --- a/OfficeIMO.Tests/OfficeIMO.Tests.csproj +++ b/OfficeIMO.Tests/OfficeIMO.Tests.csproj @@ -37,6 +37,7 @@ + @@ -93,8 +94,4 @@ - - - - diff --git a/OfficeIMO.Tests/PackageDependencyGuardrails.cs b/OfficeIMO.Tests/PackageDependencyGuardrails.cs index 069120eb9..11d3ef352 100644 --- a/OfficeIMO.Tests/PackageDependencyGuardrails.cs +++ b/OfficeIMO.Tests/PackageDependencyGuardrails.cs @@ -39,6 +39,7 @@ public void Projects_DoNotReferenceSixLaborsFontsPackage() { [Theory] [InlineData("OfficeIMO.Drawing/OfficeIMO.Drawing.csproj")] [InlineData("OfficeIMO.Pdf/OfficeIMO.Pdf.csproj")] + [InlineData("OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj")] public void DependencyLightProjects_HaveNoPackageReferences(string relativeProjectPath) { var projectPath = GetRepositoryPath(relativeProjectPath); Assert.True(File.Exists(projectPath), "Project file is missing: " + projectPath); diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs new file mode 100644 index 000000000..c1ae65b06 --- /dev/null +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -0,0 +1,1651 @@ +using DocumentFormat.OpenXml.Spreadsheet; +using OfficeIMO.Excel; +using OfficeIMO.Excel.Pdf; +using System.Globalization; +using System.Text; +using UglyToad.PdfPig; +using Xunit; +using PdfCore = OfficeIMO.Pdf; + +namespace OfficeIMO.Tests; + +public partial class Excel { + [Fact] + public void SaveAsPdf_ExcelWorkbook_Exports_Worksheet_UsedRange_To_Table() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfWorkbook.xlsx"); + string pdfPath = Path.Combine(_directoryWithFiles, "ExcelPdfWorkbook.pdf"); + + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Sales")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Product"); + sheet.Cell(1, 2, "Amount"); + sheet.Cell(2, 1, "Licenses"); + sheet.Cell(2, 2, 1250.5); + sheet.Cell(3, 1, "Support"); + sheet.Cell(3, 2, 250); + document.Save(false); + + document.SaveAsPdf(pdfPath); + } + + Assert.True(File.Exists(pdfPath)); + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Sales", text); + Assert.Contains("Product", text); + Assert.Contains("Licenses", text); + Assert.Contains("1250.5", text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Respects_Selected_Sheets() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfSelectedSheets.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath)) { + ExcelSheet summary = document.AddWorkSheet("Summary"); + summary.Cell(1, 1, "Metric"); + summary.Cell(2, 1, "SelectedValue"); + ExcelSheet internalSheet = document.AddWorkSheet("Internal"); + internalSheet.Cell(1, 1, "HiddenValue"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + SheetNames = new[] { "Summary" } + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Summary", text); + Assert.Contains("SelectedValue", text); + Assert.DoesNotContain("HiddenValue", text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Exports_Worksheet_Images() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfImages.xlsx"); + + byte[] imageBytes = CreateMinimalRgbPng(); + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Images")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "ImageMarker"); + sheet.AddImage(2, 1, imageBytes, "image/png", widthPixels: 24, heightPixels: 16, name: "Pdf Logo", altText: "PDF logo"); + + ExcelImage image = Assert.Single(sheet.Images); + Assert.Equal("Pdf Logo", image.Name); + Assert.Equal("PDF logo", image.Description); + Assert.Equal(2, image.RowIndex); + Assert.Equal(1, image.ColumnIndex); + Assert.Equal(24, image.WidthPixels); + Assert.Equal(16, image.HeightPixels); + Assert.Equal("image/png", image.ContentType); + Assert.Equal(imageBytes, image.GetBytes()); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0 + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.Contains("ImageMarker", pdf.GetPage(1).Text); + + var extractedImages = PdfCore.PdfImageExtractor.ExtractImages(bytes); + var extractedImage = Assert.Single(extractedImages); + Assert.Equal(1, extractedImage.PageNumber); + Assert.Equal("png", extractedImage.FileExtension); + Assert.Equal("image/png", extractedImage.MimeType); + Assert.True(extractedImage.IsImageFile); + Assert.Equal(1, extractedImage.Width); + Assert.Equal(1, extractedImage.Height); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Embeds_Worksheet_Images_In_Anchored_Table_Cells() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfAnchoredImageCell.xlsx"); + + byte[] imageBytes = CreateMinimalRgbPng(); + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Images")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Label"); + sheet.Cell(1, 2, "Visual"); + sheet.Cell(2, 1, "BeforeImageRow"); + sheet.Cell(3, 1, "AnchoredImageRow"); + sheet.Cell(4, 1, "AfterImageRow"); + sheet.AddImage(3, 2, imageBytes, "image/png", widthPixels: 72, heightPixels: 72, name: "Anchored Cell Image"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(420, 360), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + UglyToad.PdfPig.Content.Page page = pdf.GetPage(1); + double beforeRowY = FindWordStartY(page, "BeforeImageRow"); + double anchoredRowY = FindWordStartY(page, "AnchoredImageRow"); + double afterRowY = FindWordStartY(page, "AfterImageRow"); + + double gapBeforeAnchoredRow = beforeRowY - anchoredRowY; + double gapAfterAnchoredRow = anchoredRowY - afterRowY; + Assert.True(gapAfterAnchoredRow > gapBeforeAnchoredRow + 20, "The image should increase the anchored table row height instead of flowing before the table."); + + var extractedImage = Assert.Single(PdfCore.PdfImageExtractor.ExtractImages(bytes)); + Assert.Equal(1, extractedImage.PageNumber); + Assert.Equal("image/png", extractedImage.MimeType); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Omits_Hidden_Sheets_By_Default() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHiddenSheets.xlsx"); + + byte[] visibleBytes; + byte[] explicitHiddenBytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath)) { + ExcelSheet visible = document.AddWorkSheet("Visible"); + visible.Cell(1, 1, "VisibleSheetValue"); + ExcelSheet hidden = document.AddWorkSheet("Hidden"); + hidden.Cell(1, 1, "HiddenSheetValue"); + hidden.SetHidden(true); + Assert.True(hidden.Hidden); + document.Save(false); + + visibleBytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0 + }); + + explicitHiddenBytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + SheetNames = new[] { "Hidden" }, + IncludeSheetHeadings = false, + HeaderRowCount = 0 + }); + } + + using PdfDocument visiblePdf = PdfDocument.Open(new MemoryStream(visibleBytes)); + string visibleText = visiblePdf.GetPage(1).Text; + Assert.Contains("VisibleSheetValue", visibleText); + Assert.DoesNotContain("HiddenSheetValue", visibleText); + + using PdfDocument explicitHiddenPdf = PdfDocument.Open(new MemoryStream(explicitHiddenBytes)); + string hiddenText = explicitHiddenPdf.GetPage(1).Text; + Assert.Contains("HiddenSheetValue", hiddenText); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Applies_FirstParty_PageSetup_Options() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfPageSetup.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "PageSetup")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Name"); + sheet.Cell(1, 2, "Value"); + sheet.Cell(2, 1, "PageWidth"); + sheet.Cell(2, 2, "Custom"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + PageSize = new PdfCore.PageSize(360, 240), + Margins = PdfCore.PageMargins.Uniform(24), + HeaderRowCount = 1 + }); + } + + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + Assert.Single(info.Pages); + Assert.Equal(360, info.Pages[0].Width, 1); + Assert.Equal(240, info.Pages[0].Height, 1); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Uses_Worksheet_Print_Area() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfPrintArea.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Report")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "OutsideTop"); + sheet.Cell(2, 2, "InsideHeader"); + sheet.Cell(3, 2, "InsideValue"); + sheet.Cell(4, 4, "OutsideRight"); + document.SetPrintArea(sheet, "B2:C3"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("InsideHeader", text); + Assert.Contains("InsideValue", text); + Assert.DoesNotContain("OutsideTop", text); + Assert.DoesNotContain("OutsideRight", text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Uses_Worksheet_Orientation_And_Margins() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfWorksheetPageSetup.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "PageSetup")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Name"); + sheet.Cell(2, 1, "WorksheetPageSetup"); + sheet.SetOrientation(ExcelPageOrientation.Landscape); + sheet.SetMargins(left: 0.25, right: 0.25, top: 0.5, bottom: 0.5); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false + }); + } + + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + PdfCore.PdfPageInfo page = Assert.Single(info.Pages); + Assert.True(page.Width > page.Height, $"Expected worksheet landscape orientation. Width: {page.Width}, height: {page.Height}."); + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + double firstLetterX = pdf.GetPage(1).Letters.First(letter => letter.Value == "N").StartBaseLine.X; + Assert.InRange(firstLetterX, 17D, 36D); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Uses_Print_Title_Rows_As_Repeating_Table_Header() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfPrintTitles.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "LongReport")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "RegionHeader"); + sheet.Cell(1, 2, "AmountHeader"); + for (int row = 3; row <= 90; row++) { + sheet.Cell(row, 1, "Region " + row.ToString(CultureInfo.InvariantCulture)); + sheet.Cell(row, 2, row); + } + + document.SetPrintArea(sheet, "A3:B90"); + document.SetPrintTitles(sheet, firstRow: 1, lastRow: 1, firstCol: null, lastCol: null); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(300, 220), + Margins = PdfCore.PageMargins.Uniform(18) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.True(pdf.NumberOfPages > 1); + Assert.Contains("RegionHeader", pdf.GetPage(1).Text); + Assert.Contains("RegionHeader", pdf.GetPage(2).Text); + Assert.Contains("Region 3", pdf.GetPage(1).Text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Honors_Manual_Row_Page_Breaks() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfManualRowPageBreaks.xlsx"); + + byte[] bytes; + byte[] disabledBytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "ManualBreaks")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Name"); + sheet.Cell(1, 2, "Value"); + sheet.Cell(2, 1, "BeforeBreak"); + sheet.Cell(2, 2, "FirstPage"); + sheet.Cell(3, 1, "BreakTail"); + sheet.Cell(3, 2, "StillFirstPage"); + sheet.Cell(4, 1, "AfterBreak"); + sheet.Cell(4, 2, "SecondPage"); + sheet.Cell(5, 1, "SecondTail"); + sheet.Cell(5, 2, "SecondPageTail"); + sheet.AddManualRowPageBreak(3); + + Assert.Equal(new[] { 3 }, sheet.GetManualRowPageBreaks()); + document.Save(false); + + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(420, 420), + Margins = PdfCore.PageMargins.Uniform(24) + }; + bytes = document.SaveAsPdf(options); + + options.UseWorksheetPageBreaks = false; + disabledBytes = document.SaveAsPdf(options); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.Equal(2, pdf.NumberOfPages); + string firstPage = pdf.GetPage(1).Text; + string secondPage = pdf.GetPage(2).Text; + Assert.Contains("BeforeBreak", firstPage); + Assert.Contains("BreakTail", firstPage); + Assert.DoesNotContain("AfterBreak", firstPage); + Assert.Contains("AfterBreak", secondPage); + Assert.Contains("SecondTail", secondPage); + Assert.Contains("Name", secondPage); + Assert.Contains("Value", secondPage); + + using PdfDocument disabledPdf = PdfDocument.Open(new MemoryStream(disabledBytes)); + Assert.Equal(1, disabledPdf.NumberOfPages); + Assert.Contains("AfterBreak", disabledPdf.GetPage(1).Text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Honors_Manual_Column_Page_Breaks() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfManualColumnPageBreaks.xlsx"); + + byte[] bytes; + byte[] disabledBytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "ManualColumnBreaks")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "LeftHeader"); + sheet.Cell(1, 2, "LeftTail"); + sheet.Cell(1, 3, "RightHeader"); + sheet.Cell(1, 4, "RightTail"); + sheet.Cell(2, 1, "LeftValueA"); + sheet.Cell(2, 2, "LeftValueB"); + sheet.Cell(2, 3, "RightValueC"); + sheet.Cell(2, 4, "RightValueD"); + sheet.SetColumnWidth(1, 16); + sheet.SetColumnWidth(3, 22); + sheet.AddManualColumnPageBreak(2); + + Assert.Equal(new[] { 2 }, sheet.GetManualColumnPageBreaks()); + document.Save(false); + + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(560, 320), + Margins = PdfCore.PageMargins.Uniform(24) + }; + bytes = document.SaveAsPdf(options); + + options.UseWorksheetPageBreaks = false; + disabledBytes = document.SaveAsPdf(options); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.Equal(2, pdf.NumberOfPages); + string firstPage = pdf.GetPage(1).Text; + string secondPage = pdf.GetPage(2).Text; + Assert.Contains("LeftValueA", firstPage); + Assert.Contains("LeftValueB", firstPage); + Assert.DoesNotContain("RightValueC", firstPage); + Assert.Contains("RightValueC", secondPage); + Assert.Contains("RightValueD", secondPage); + Assert.DoesNotContain("LeftValueA", secondPage); + + using PdfDocument disabledPdf = PdfDocument.Open(new MemoryStream(disabledBytes)); + Assert.Equal(1, disabledPdf.NumberOfPages); + Assert.Contains("LeftValueA", disabledPdf.GetPage(1).Text); + Assert.Contains("RightValueC", disabledPdf.GetPage(1).Text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_HeaderFooter_Text_Zones() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooter.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Dashboard")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Metric"); + sheet.Cell(2, 1, "HeaderFooterBody"); + sheet.SetHeaderFooter( + headerLeft: "Left Header", + headerCenter: "Dashboard Header", + headerRight: "Page &P of &N", + footerLeft: "Sheet &A", + footerRight: "Right Footer"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + PageSize = new PdfCore.PageSize(420, 320), + Margins = PdfCore.PageMargins.Uniform(54) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Left Header", text); + Assert.Contains("Dashboard Header", text); + Assert.Contains("Page 1 of 1", text); + Assert.Contains("Sheet Dashboard", text); + Assert.Contains("Right Footer", text); + Assert.Contains("HeaderFooterBody", text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_HeaderFooter_DateTime_And_File_Fields() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterFields.xlsx"); + DateTime printedAt = new DateTime(2026, 5, 31, 14, 35, 0); + + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + HeaderFooterDateTimeProvider = () => printedAt, + PageSize = new PdfCore.PageSize(1800, 320), + Margins = PdfCore.PageMargins.Uniform(54) + }; + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Dashboard")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "HeaderFooterFieldBody"); + sheet.SetHeaderFooter( + headerLeft: "Printed &D &T Dir &Z File &F", + footerRight: "Page &P of &N"); + document.Save(false); + + bytes = document.SaveAsPdf(options); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Printed " + printedAt.ToString("d", CultureInfo.CurrentCulture), text); + Assert.Contains(printedAt.ToString("t", CultureInfo.CurrentCulture), text); + Assert.Contains("Dir " + Path.GetDirectoryName(Path.GetFullPath(workbookPath)), text); + Assert.Contains("File " + Path.GetFileName(workbookPath), text); + Assert.Contains("Page 1 of 1", text); + Assert.Contains("HeaderFooterFieldBody", text); + Assert.DoesNotContain(options.Warnings, warning => warning.Feature == "WorksheetHeaderFooterField"); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Simple_Worksheet_HeaderFooter_Formatting() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterFormatting.xlsx"); + + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(420, 320), + Margins = PdfCore.PageMargins.Uniform(54) + }; + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Formatting")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "HeaderFooterFormattingBody"); + sheet.SetHeaderFooter( + headerCenter: "&\"Arial,Bold\"&18&KFF0000Styled Header", + footerCenter: "&\"Times New Roman,Italic\"&10&K0000FFStyled Footer"); + document.Save(false); + + bytes = document.SaveAsPdf(options); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Styled Header", text); + Assert.Contains("Styled Footer", text); + Assert.Contains("HeaderFooterFormattingBody", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("1 0 0 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0 0 1 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("Helvetica-Bold", rawPdf, StringComparison.Ordinal); + Assert.Contains("Times-Italic", rawPdf, StringComparison.Ordinal); + Assert.Contains(" 18 Tf", rawPdf, StringComparison.Ordinal); + Assert.Contains(" 10 Tf", rawPdf, StringComparison.Ordinal); + Assert.DoesNotContain(options.Warnings, warning => warning.Feature == "WorksheetHeaderFooterFormatting"); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Warns_For_Mixed_Worksheet_HeaderFooter_Formatting() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterMixedFormatting.xlsx"); + + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(420, 320), + Margins = PdfCore.PageMargins.Uniform(54) + }; + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "MixedFormatting")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "MixedFormattingBody"); + sheet.SetHeaderFooter( + headerLeft: "&KFF0000Red Left", + headerCenter: "Plain Center", + footerCenter: "Plain Footer"); + document.Save(false); + + bytes = document.SaveAsPdf(options); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Red Left", text); + Assert.Contains("Plain Center", text); + Assert.Contains("MixedFormattingBody", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.DoesNotContain("1 0 0 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains(options.Warnings, warning => warning.SheetName == "MixedFormatting" && warning.Feature == "WorksheetHeaderFooterFormatting"); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_First_And_Even_HeaderFooter_Text_Zones() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterVariants.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Ledger")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Entry"); + sheet.Cell(1, 2, "Amount"); + for (int row = 2; row <= 90; row++) { + sheet.Cell(row, 1, "Ledger row " + row.ToString(CultureInfo.InvariantCulture)); + sheet.Cell(row, 2, row * 10); + } + + sheet.SetHeaderFooter(headerCenter: "Odd Header &A", footerCenter: "Odd Footer &P"); + sheet.SetFirstPageHeaderFooter(headerCenter: "First Header &A", footerCenter: "First Footer &P"); + sheet.SetEvenPageHeaderFooter(headerCenter: "Even Header &A", footerCenter: "Even Footer &P"); + + ExcelSheet.HeaderFooterSnapshot snapshot = sheet.GetHeaderFooter(); + Assert.True(snapshot.DifferentFirstPage); + Assert.True(snapshot.DifferentOddEven); + Assert.Equal("Odd Header &A", snapshot.HeaderCenter); + Assert.Equal("First Header &A", snapshot.FirstHeaderCenter); + Assert.Equal("Even Header &A", snapshot.EvenHeaderCenter); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(48) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.True(pdf.NumberOfPages >= 3); + string firstPage = pdf.GetPage(1).Text; + string secondPage = pdf.GetPage(2).Text; + string thirdPage = pdf.GetPage(3).Text; + Assert.Contains("First Header Ledger", firstPage); + Assert.Contains("First Footer 1", firstPage); + Assert.Contains("Even Header Ledger", secondPage); + Assert.Contains("Even Footer 2", secondPage); + Assert.Contains("Odd Header Ledger", thirdPage); + Assert.Contains("Odd Footer 3", thirdPage); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_HeaderFooter_Images() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterImages.xlsx"); + + byte[] imageBytes = CreateMinimalRgbPng(); + byte[] bytes; + byte[] disabledBytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Dashboard")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Metric"); + sheet.Cell(2, 1, "HeaderFooterImageBody"); + sheet.SetHeaderFooter(headerCenter: "Logo Header"); + sheet.SetHeaderImage(HeaderFooterPosition.Center, imageBytes, "image/png", widthPoints: 24, heightPoints: 16); + + ExcelSheet.HeaderFooterSnapshot snapshot = sheet.GetHeaderFooter(); + Assert.True(snapshot.HeaderHasPicturePlaceholder); + Assert.NotNull(snapshot.HeaderCenterImage); + Assert.Equal(HeaderFooterPosition.Center, snapshot.HeaderCenterImage!.Position); + Assert.Equal("image/png", snapshot.HeaderCenterImage.ContentType); + Assert.Equal(24, snapshot.HeaderCenterImage.WidthPoints); + Assert.Equal(16, snapshot.HeaderCenterImage.HeightPoints); + Assert.Equal(imageBytes, snapshot.HeaderCenterImage.Bytes); + + document.Save(false); + + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + PageSize = new PdfCore.PageSize(420, 320), + Margins = PdfCore.PageMargins.Uniform(54) + }; + bytes = document.SaveAsPdf(options); + + disabledBytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + UseWorksheetHeaderFooterImages = false, + PageSize = new PdfCore.PageSize(420, 320), + Margins = PdfCore.PageMargins.Uniform(54) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Logo Header", text); + Assert.Contains("HeaderFooterImageBody", text); + + var extractedImages = PdfCore.PdfImageExtractor.ExtractImages(bytes); + var extractedImage = Assert.Single(extractedImages); + Assert.Equal(1, extractedImage.PageNumber); + Assert.Equal("png", extractedImage.FileExtension); + Assert.Equal("image/png", extractedImage.MimeType); + Assert.True(extractedImage.IsImageFile); + + Assert.Empty(PdfCore.PdfImageExtractor.ExtractImages(disabledBytes)); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Can_Disable_Worksheet_HeaderFooter_Text_Zones() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterDisabled.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Dashboard")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Metric"); + sheet.Cell(2, 1, "BodyOnly"); + sheet.SetHeaderFooter(headerCenter: "DoNotExportHeader", footerCenter: "DoNotExportFooter"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + UseWorksheetHeadersAndFooters = false + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("BodyOnly", text); + Assert.DoesNotContain("DoNotExportHeader", text); + Assert.DoesNotContain("DoNotExportFooter", text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Basic_Cell_Font_And_Fill_Styles() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfCellStyles.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Styled")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.CellAt(1, 1) + .SetValue("StyledCell") + .SetBold() + .SetItalic() + .SetUnderline() + .SetFontColor("112233") + .SetFillColor("DDEEFF"); + sheet.CellAt(1, 2).SetValue("PlainCell"); + + ExcelCellStyleSnapshot style = sheet.CellAt(1, 1).GetStyle(); + Assert.True(style.Bold); + Assert.True(style.Italic); + Assert.True(style.Underline); + Assert.Equal("112233", style.FontColorHex); + Assert.Equal("DDEEFF", style.FillColorHex); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("StyledCell", text); + Assert.Contains("PlainCell", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.067 0.133 0.2 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.867 0.933 1 rg", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Conditional_ColorScale_Fills() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfConditionalColorScale.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Conditional")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Score"); + sheet.Cell(2, 1, 0); + sheet.Cell(3, 1, 50); + sheet.Cell(4, 1, 100); + sheet.AddConditionalColorScale("A2:A4", "FFFF0000", "FF00FF00"); + + ExcelConditionalFormattingInfo rule = Assert.Single(sheet.GetConditionalFormattingRules("A2:A4")); + Assert.Equal("ColorScale", rule.Type); + Assert.Equal(new[] { "FFFF0000", "FF00FF00" }, rule.ColorScaleColors); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 240), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Score", text); + Assert.Contains("100", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("1 0 0 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.502 0.502 0 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0 1 0 rg", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Conditional_DataBar_Overlays() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfConditionalDataBar.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Conditional")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Score"); + sheet.Cell(2, 1, 0); + sheet.Cell(3, 1, 50); + sheet.Cell(4, 1, 100); + sheet.AddConditionalDataBar("A2:A4", "FF5B9BD5"); + + ExcelConditionalFormattingInfo rule = Assert.Single(sheet.GetConditionalFormattingRules("A2:A4")); + Assert.Equal("DataBar", rule.Type); + Assert.Equal("FF5B9BD5", rule.DataBarColor); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 240), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Score", text); + Assert.Contains("100", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + int barFillCount = rawPdf.Split(new[] { "0.357 0.608 0.835 rg" }, StringSplitOptions.None).Length - 1; + + Assert.Equal(2, barFillCount); + Assert.Contains(" re f", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Conditional_IconSet_Indicators() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfConditionalIconSet.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Conditional")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Score"); + sheet.Cell(2, 1, 0); + sheet.Cell(3, 1, 50); + sheet.Cell(4, 1, 100); + sheet.AddConditionalIconSet("A2:A4", IconSetValues.ThreeTrafficLights1, showValue: true, reverseIconOrder: false); + + ExcelConditionalFormattingInfo rule = Assert.Single(sheet.GetConditionalFormattingRules("A2:A4")); + Assert.Equal("IconSet", rule.Type); + Assert.Equal("ThreeTrafficLights1", rule.IconSet); + Assert.True(rule.IconSetShowValue); + Assert.False(rule.IconSetReverse); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 240), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Score", text); + Assert.Contains("100", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.753 0.314 0.302 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("1 0.753 0 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.388 0.608 0.278 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains(" c ", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_External_Cell_Hyperlinks() { + const string linkUri = "https://github.com/EvotecIT/OfficeIMO"; + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHyperlinks.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Links")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Name"); + sheet.SetHyperlink(2, 1, linkUri, display: "OfficeIMO"); + + ExcelHyperlinkSnapshot hyperlink = Assert.Single(sheet.GetHyperlinks()).Value; + Assert.True(hyperlink.IsExternal); + Assert.Equal(linkUri, hyperlink.Target); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("OfficeIMO", text); + + PdfCore.PdfLogicalDocument logical = PdfCore.PdfLogicalDocument.Load(bytes); + PdfCore.PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByUri(linkUri)); + Assert.Equal("OfficeIMO", link.Contents); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("/Subtype /Link", rawPdf, StringComparison.Ordinal); + Assert.Contains("/URI (https://github.com/EvotecIT/OfficeIMO)", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Internal_Cell_Hyperlinks_To_Cell_Destinations() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfInternalHyperlinks.xlsx"); + + byte[] bytes; + byte[] summaryOnlyBytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath)) { + ExcelSheet summary = document.AddWorkSheet("Summary"); + summary.Cell(1, 1, "Name"); + summary.SetInternalLink(2, 1, "Details!B3", display: "Open Details B3"); + ExcelSheet details = document.AddWorkSheet("Details"); + details.Cell(1, 1, "Details Target"); + details.Cell(2, 1, "DestinationValue"); + details.Cell(3, 2, "CellSpecificTarget"); + + ExcelHyperlinkSnapshot hyperlink = Assert.Single(summary.GetHyperlinks()).Value; + Assert.False(hyperlink.IsExternal); + Assert.Equal("'Details'!B3", hyperlink.Target); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = true, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + + summaryOnlyBytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + SheetNames = new[] { "Summary" }, + IncludeSheetHeadings = true, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + PdfCore.PdfLogicalDocument logical = PdfCore.PdfLogicalDocument.Load(bytes); + PdfCore.PdfNamedDestination destination = Assert.Single(logical.NamedDestinations, item => item.Name.EndsWith("-b3", StringComparison.Ordinal)); + PdfCore.PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByDestinationName(destination.Name)); + Assert.True(link.IsNamedDestinationLink); + Assert.Equal("Open Details B3", link.Contents); + Assert.Equal(destination.Name, link.DestinationName); + Assert.Contains("details", destination.Name, StringComparison.Ordinal); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("/Subtype /Link", rawPdf, StringComparison.Ordinal); + Assert.Contains("/S /GoTo", rawPdf, StringComparison.Ordinal); + + PdfCore.PdfLogicalDocument summaryOnly = PdfCore.PdfLogicalDocument.Load(summaryOnlyBytes); + Assert.DoesNotContain(summaryOnly.NamedDestinations, item => item.Name.IndexOf("details", StringComparison.Ordinal) >= 0); + Assert.DoesNotContain(summaryOnly.Links, link => link.IsNamedDestinationLink); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Cell_Alignment_And_Borders() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfCellAlignmentBorders.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "StyleLayout")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Label"); + sheet.Cell(1, 2, "ZZ"); + sheet.Cell(2, 1, "Reference"); + sheet.Cell(2, 2, "LeftInColumn"); + sheet.CellAlign(1, 2, DocumentFormat.OpenXml.Spreadsheet.HorizontalAlignmentValues.Right); + sheet.CellVerticalAlign(1, 2, DocumentFormat.OpenXml.Spreadsheet.VerticalAlignmentValues.Bottom); + sheet.CellBorder(1, 2, DocumentFormat.OpenXml.Spreadsheet.BorderStyleValues.Medium, "445566"); + + ExcelCellStyleSnapshot style = sheet.CellAt(1, 2).GetStyle(); + Assert.Equal("right", style.HorizontalAlignment); + Assert.Equal("bottom", style.VerticalAlignment); + Assert.NotNull(style.Border); + Assert.Equal("medium", style.Border!.Left!.Style); + Assert.Equal("FF445566", style.Border.Left.ColorArgb); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + var page = pdf.GetPage(1); + string text = page.Text; + Assert.Contains("ZZ", text); + Assert.Contains("LeftInColumn", text); + + double rightAlignedX = FindWordStartX(page, "ZZ"); + double sameColumnLeftX = FindWordStartX(page, "LeftInColumn"); + Assert.True(rightAlignedX > sameColumnLeftX + 70D, $"Expected right-aligned cell text to move toward the cell's right edge. Right x: {rightAlignedX:0.##}, left-reference x: {sameColumnLeftX:0.##}."); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.267 0.333 0.4 RG", rawPdf, StringComparison.Ordinal); + Assert.Contains("1.25 w", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Dashed_Cell_Border_Styles() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfCellBorderDashStyles.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "BorderStyles")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Dashed"); + sheet.Cell(1, 2, "Dotted"); + sheet.Cell(1, 3, "DashDot"); + sheet.CellBorder(1, 1, BorderStyleValues.Dashed, "123456"); + sheet.CellBorder(1, 2, BorderStyleValues.Dotted, "654321"); + sheet.CellBorder(1, 3, BorderStyleValues.MediumDashDot, "445566"); + + Assert.Equal("dashed", sheet.CellAt(1, 1).GetStyle().Border!.Left!.Style); + Assert.Equal("dotted", sheet.CellAt(1, 2).GetStyle().Border!.Left!.Style); + Assert.Equal("mediumdashdot", sheet.CellAt(1, 3).GetStyle().Border!.Left!.Style); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using (PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes))) { + string text = pdf.GetPage(1).Text; + Assert.Contains("Dashed", text); + Assert.Contains("Dotted", text); + Assert.Contains("DashDot", text); + } + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("[1.5 0.75] 0 d", rawPdf, StringComparison.Ordinal); + Assert.Contains("[0.5 0.75] 0 d", rawPdf, StringComparison.Ordinal); + Assert.Contains("[3.75 1.875 1.25 1.875] 0 d", rawPdf, StringComparison.Ordinal); + Assert.Contains("1 J", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Double_And_Diagonal_Cell_Borders() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfCellBorderDoubleDiagonal.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "BorderStyles")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Double"); + sheet.Cell(1, 2, "Diagonal"); + sheet.CellBorder(1, 1, BorderStyleValues.Double, "123456"); + sheet.CellDiagonalBorder(1, 2, BorderStyleValues.Double, "654321", diagonalUp: true, diagonalDown: true); + + ExcelCellStyleSnapshot doubleStyle = sheet.CellAt(1, 1).GetStyle(); + Assert.Equal("double", doubleStyle.Border!.Top!.Style); + ExcelCellStyleSnapshot diagonalStyle = sheet.CellAt(1, 2).GetStyle(); + Assert.True(diagonalStyle.Border!.DiagonalUp); + Assert.True(diagonalStyle.Border.DiagonalDown); + Assert.Equal("double", diagonalStyle.Border.Diagonal!.Style); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(320, 220), + Margins = PdfCore.PageMargins.Uniform(30) + }); + } + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.071 0.204 0.337 RG", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.396 0.263 0.129 RG", rawPdf, StringComparison.Ordinal); + Assert.True(rawPdf.Split(new[] { " S" }, StringSplitOptions.None).Length - 1 >= 10, "Expected Excel double and diagonal borders to emit multiple stroked lines."); + Assert.True(rawPdf.Contains(" m ", StringComparison.Ordinal) && rawPdf.Contains(" l S", StringComparison.Ordinal), "Expected Excel diagonal borders to emit PDF line segments."); + + using (PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes))) { + string text = pdf.GetPage(1).Text; + Assert.Contains("Double", text); + Assert.Contains("Diagonal", text); + } + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfNumberFormats.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Formats")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Kind"); + sheet.Cell(1, 2, "Value"); + sheet.Cell(2, 1, "Currency"); + sheet.CellAt(2, 2).SetValue(1234.5).Currency(2, CultureInfo.GetCultureInfo("en-US")); + sheet.Cell(3, 1, "Percent"); + sheet.CellAt(3, 2).SetValue(0.257).Percent(1); + sheet.Cell(4, 1, "Date"); + sheet.CellAt(4, 2).SetValue(new DateTime(2026, 1, 15)).Date("yyyy-mm-dd"); + + ExcelCellStyleSnapshot currencyStyle = sheet.CellAt(2, 2).GetStyle(); + Assert.Equal("\"$\"#,##0.00", currencyStyle.NumberFormatCode); + ExcelCellStyleSnapshot percentStyle = sheet.CellAt(3, 2).GetStyle(); + Assert.Equal("0.0%", percentStyle.NumberFormatCode); + ExcelCellStyleSnapshot dateStyle = sheet.CellAt(4, 2).GetStyle(); + Assert.Equal("yyyy-mm-dd", dateStyle.NumberFormatCode); + Assert.True(dateStyle.IsDateLike); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(420, 260), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("$1,234.50", text); + Assert.Contains("25.7%", text); + Assert.Contains("2026-01-15", text); + Assert.DoesNotContain("1234.5", text); + Assert.DoesNotContain("0.257", text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Uses_Worksheet_Column_Widths_And_Print_Scale() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfColumnWidths.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Widths")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "ARef"); + sheet.Cell(1, 2, "WideColumn"); + sheet.Cell(1, 3, "Tail"); + sheet.SetColumnWidth(1, 8); + sheet.SetColumnWidth(2, 32); + sheet.SetColumnWidth(3, 8); + sheet.SetPageSetup(scale: 50); + + IReadOnlyList columns = sheet.GetColumnDefinitions(); + Assert.Equal(3, columns.Count); + Assert.Equal(32, columns[1].Width); + Assert.True(columns[1].CustomWidth); + Assert.Equal((uint)50, sheet.GetPageSetup().Scale); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + var page = pdf.GetPage(1); + string text = page.Text; + Assert.Contains("ARef", text); + Assert.Contains("WideColumn", text); + Assert.Contains("Tail", text); + + double firstColumnX = page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .Min(letter => letter.StartBaseLine.X); + double wideColumnX = FindFirstLetterStartX(page, "W"); + double tailX = FindFirstLetterStartX(page, "T"); + Assert.True(tailX - wideColumnX > (wideColumnX - firstColumnX) * 2D, $"Expected worksheet column width proportions to make the middle column visibly wider. A: {firstColumnX:0.##}, B: {wideColumnX:0.##}, C: {tailX:0.##}."); + Assert.True(tailX < 190D, $"Expected worksheet print scale to narrow the rendered table. Tail x: {tailX:0.##}."); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Uses_Worksheet_Row_Heights() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfRowHeights.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Heights")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "TopRow"); + sheet.Cell(2, 1, "TallRow"); + sheet.Cell(3, 1, "AfterTall"); + sheet.SetRowHeight(2, 60); + + ExcelRowSnapshot row = Assert.Single(sheet.GetRowDefinitions()); + Assert.Equal(2, row.Index); + Assert.Equal(60, row.Height); + Assert.True(row.CustomHeight); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(260, 260), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + var page = pdf.GetPage(1); + string text = page.Text; + Assert.Contains("TopRow", text); + Assert.Contains("TallRow", text); + Assert.Contains("AfterTall", text); + + double topY = FindWordStartY(page, "TopRow"); + double tallY = FindWordStartY(page, "TallRow"); + double afterY = FindWordStartY(page, "AfterTall"); + double defaultGap = topY - tallY; + double customGap = tallY - afterY; + Assert.True(customGap > defaultGap * 2D, $"Expected worksheet row height to create a visibly taller second PDF table row. Default gap: {defaultGap:0.##}, custom gap: {customGap:0.##}."); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Omits_Hidden_Rows_And_Columns() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHiddenRowsColumns.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "VisibleOnly")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "VisibleHeader"); + sheet.Cell(1, 2, "HiddenColumnValue"); + sheet.Cell(2, 1, "HiddenRowValue"); + sheet.Cell(3, 1, "VisibleTail"); + sheet.SetColumnHidden(2, true); + sheet.SetRowHidden(2, true); + + ExcelColumnSnapshot column = Assert.Single(sheet.GetColumnDefinitions()); + Assert.Equal(2, column.StartIndex); + Assert.Equal(2, column.EndIndex); + Assert.True(column.Hidden); + + ExcelRowSnapshot row = Assert.Single(sheet.GetRowDefinitions()); + Assert.Equal(2, row.Index); + Assert.True(row.Hidden); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(320, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("VisibleHeader", text); + Assert.Contains("VisibleTail", text); + Assert.DoesNotContain("HiddenColumnValue", text); + Assert.DoesNotContain("HiddenRowValue", text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Merged_Cells_To_Table_Spans() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfMergedCells.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Merged")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "MergedTitle"); + sheet.Cell(1, 3, "TailCell"); + sheet.Cell(2, 1, "ColumnA"); + sheet.Cell(2, 2, "ColumnB"); + sheet.Cell(2, 3, "ColumnC"); + sheet.MergeRange("A1:B1"); + + ExcelMergedRangeSnapshot mergedRange = Assert.Single(sheet.GetMergedRanges()); + Assert.Equal("A1:B1", mergedRange.A1Range); + Assert.Equal(1, mergedRange.StartRow); + Assert.Equal(1, mergedRange.StartColumn); + Assert.Equal(1, mergedRange.EndRow); + Assert.Equal(2, mergedRange.EndColumn); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + var page = pdf.GetPage(1); + string text = page.Text; + Assert.Contains("MergedTitle", text); + Assert.Contains("TailCell", text); + Assert.Contains("ColumnA", text); + Assert.Contains("ColumnB", text); + Assert.Contains("ColumnC", text); + + double mergedTitleX = FindWordStartX(page, "MergedTitle"); + double tailCellX = FindWordStartX(page, "TailCell"); + double columnBX = FindWordStartX(page, "ColumnB"); + double columnCX = FindWordStartX(page, "ColumnC"); + + Assert.True(tailCellX > columnBX + 30D, $"Expected tail cell after A1:B1 merge to render in the third visual column. Tail x: {tailCellX:0.##}, ColumnB x: {columnBX:0.##}."); + Assert.InRange(tailCellX, columnCX - 4D, columnCX + 4D); + Assert.True(mergedTitleX < columnBX, $"Expected merged title to start in the first visual column. Title x: {mergedTitleX:0.##}, ColumnB x: {columnBX:0.##}."); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Exports_Worksheet_Chart_Snapshots() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfCharts.xlsx"); + + byte[] bytes; + byte[] disabledBytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Charts")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Category"); + sheet.Cell(1, 2, "Actual"); + sheet.Cell(1, 3, "Target"); + sheet.Cell(2, 1, "Jan"); + sheet.Cell(2, 2, 12); + sheet.Cell(2, 3, 10); + sheet.Cell(3, 1, "Feb"); + sheet.Cell(3, 2, 18); + sheet.Cell(3, 3, 16); + sheet.Cell(4, 1, "Mar"); + sheet.Cell(4, 2, 24); + sheet.Cell(4, 3, 20); + sheet.AddChartFromRange("A1:C4", row: 1, column: 5, widthPixels: 360, heightPixels: 220, type: ExcelChartType.ColumnClustered, title: "Revenue Chart"); + + ExcelChart chart = Assert.Single(sheet.Charts); + Assert.True(chart.TryGetSnapshot(out ExcelChartSnapshot snapshot)); + Assert.Equal("Revenue Chart", snapshot.Title); + Assert.Equal(ExcelChartType.ColumnClustered, snapshot.ChartType); + Assert.Equal(3, snapshot.Data.Categories.Count); + Assert.Equal(2, snapshot.Data.Series.Count); + + document.Save(false); + + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(480, 360), + Margins = PdfCore.PageMargins.Uniform(24) + }; + bytes = document.SaveAsPdf(options); + disabledBytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + UseWorksheetCharts = false, + PageSize = new PdfCore.PageSize(480, 360), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Revenue Chart", text); + Assert.Contains("Actual", text); + Assert.Contains("Target", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.122 0.306 0.475 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.184 0.435 0.243 rg", rawPdf, StringComparison.Ordinal); + + using PdfDocument disabledPdf = PdfDocument.Open(new MemoryStream(disabledBytes)); + Assert.DoesNotContain("Revenue Chart", disabledPdf.GetPage(1).Text); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Exports_Pie_And_Doughnut_Chart_Snapshots() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfPieDoughnutCharts.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Charts")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Category"); + sheet.Cell(1, 2, "Control Share"); + sheet.Cell(2, 1, "Compliant"); + sheet.Cell(2, 2, 62); + sheet.Cell(3, 1, "Partial"); + sheet.Cell(3, 2, 21); + sheet.Cell(4, 1, "Non-compliant"); + sheet.Cell(4, 2, 11); + sheet.Cell(5, 1, "Not assessed"); + sheet.Cell(5, 2, 6); + sheet.AddChartFromRange("A1:B5", row: 1, column: 4, widthPixels: 280, heightPixels: 180, type: ExcelChartType.Pie, title: "Control Status Pie"); + sheet.AddChartFromRange("A1:B5", row: 12, column: 4, widthPixels: 280, heightPixels: 180, type: ExcelChartType.Doughnut, title: "Control Status Doughnut"); + + List charts = sheet.Charts.ToList(); + Assert.Equal(2, charts.Count); + Assert.All(charts, chart => Assert.True(chart.TryGetSnapshot(out _))); + Assert.Equal(ExcelChartType.Pie, charts[0].ChartType); + Assert.Equal(ExcelChartType.Doughnut, charts[1].ChartType); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(480, 520), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Control Status Pie", text); + Assert.Contains("Control Status Doughnut", text); + Assert.Contains("Compliant", text); + Assert.Contains("Non-compliant", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.122 0.306 0.475 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.184 0.435 0.243 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.722 0.353 0.137 rg", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Exports_Area_Chart_Snapshots() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfAreaCharts.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Charts")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Quarter"); + sheet.Cell(1, 2, "Services"); + sheet.Cell(1, 3, "Licenses"); + sheet.Cell(2, 1, "Q1"); + sheet.Cell(2, 2, 36); + sheet.Cell(2, 3, 19); + sheet.Cell(3, 1, "Q2"); + sheet.Cell(3, 2, 44); + sheet.Cell(3, 3, 25); + sheet.Cell(4, 1, "Q3"); + sheet.Cell(4, 2, 50); + sheet.Cell(4, 3, 31); + sheet.Cell(5, 1, "Q4"); + sheet.Cell(5, 2, 54); + sheet.Cell(5, 3, 34); + sheet.AddChartFromRange("A1:C5", row: 1, column: 5, widthPixels: 360, heightPixels: 220, type: ExcelChartType.Area, title: "Revenue Area"); + sheet.AddChartFromRange("A1:C5", row: 14, column: 5, widthPixels: 360, heightPixels: 220, type: ExcelChartType.AreaStacked100, title: "Revenue Mix Area"); + + List charts = sheet.Charts.ToList(); + Assert.Equal(2, charts.Count); + Assert.All(charts, chart => Assert.True(chart.TryGetSnapshot(out _))); + Assert.Equal(ExcelChartType.Area, charts[0].ChartType); + Assert.Equal(ExcelChartType.AreaStacked100, charts[1].ChartType); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(520, 620), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Revenue Area", text); + Assert.Contains("Revenue Mix Area", text); + Assert.Contains("Services", text); + Assert.Contains("Licenses", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.122 0.306 0.475 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.184 0.435 0.243 RG", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Exports_Scatter_Chart_Snapshots() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfScatterCharts.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Charts")) { + ExcelSheet sheet = document.Sheets[0]; + var data = new ExcelChartData( + new[] { "1", "2", "4", "8", "16" }, + new[] { + new ExcelChartSeries("Latency", new[] { 9D, 7D, 5.5D, 4.2D, 3.8D }, ExcelChartType.Scatter), + new ExcelChartSeries("Throughput", new[] { 2D, 3.5D, 6D, 7.5D, 9D }, ExcelChartType.Scatter) + }); + + sheet.AddChart(data, row: 1, column: 5, widthPixels: 360, heightPixels: 220, type: ExcelChartType.Scatter, title: "Scale Scatter"); + + ExcelChart chart = Assert.Single(sheet.Charts); + Assert.True(chart.TryGetSnapshot(out ExcelChartSnapshot snapshot)); + Assert.Equal(ExcelChartType.Scatter, snapshot.ChartType); + Assert.Equal(5, snapshot.Data.Categories.Count); + Assert.Equal(2, snapshot.Data.Series.Count); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(480, 360), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Scale Scatter", text); + Assert.Contains("Latency", text); + Assert.Contains("Throughput", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.122 0.306 0.475 RG", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.184 0.435 0.243 RG", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Exports_Radar_Chart_Snapshots() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfRadarCharts.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Charts")) { + ExcelSheet sheet = document.Sheets[0]; + var data = new ExcelChartData( + new[] { "Quality", "Speed", "Cost", "Coverage", "Risk" }, + new[] { + new ExcelChartSeries("Current", new[] { 7D, 6D, 5D, 8D, 4D }, ExcelChartType.Radar), + new ExcelChartSeries("Target", new[] { 9D, 8D, 7D, 9D, 6D }, ExcelChartType.Radar) + }); + + sheet.AddChart(data, row: 1, column: 5, widthPixels: 360, heightPixels: 220, type: ExcelChartType.Radar, title: "Capability Radar"); + + ExcelChart chart = Assert.Single(sheet.Charts); + Assert.True(chart.TryGetSnapshot(out ExcelChartSnapshot snapshot)); + Assert.Equal(ExcelChartType.Radar, snapshot.ChartType); + Assert.Equal(5, snapshot.Data.Categories.Count); + Assert.Equal(2, snapshot.Data.Series.Count); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(480, 360), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Capability Radar", text); + Assert.Contains("Current", text); + Assert.Contains("Target", text); + + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.122 0.306 0.475 rg", rawPdf, StringComparison.Ordinal); + Assert.Contains("0.184 0.435 0.243 rg", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Rejects_Invalid_Options() { + Assert.Throws(() => new ExcelPdfSaveOptions { HeaderRowCount = -1 }); + Assert.Throws(() => new ExcelPdfSaveOptions { MaxRowsPerSheet = 0 }); + } + + [Fact] + public void SaveAsPdf_ExcelWorkbook_Reports_Unsupported_Export_Features() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfUnsupportedFeatureWarnings.xlsx"); + + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + MaxRowsPerSheet = 2, + PageSize = new PdfCore.PageSize(460, 320), + Margins = PdfCore.PageMargins.Uniform(24) + }; + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Warnings")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Name"); + sheet.Cell(1, 2, "Value"); + sheet.Cell(2, 1, "Alpha"); + sheet.Cell(2, 2, 10); + sheet.Cell(3, 1, "Beta"); + sheet.Cell(3, 2, 20); + sheet.SetHeaderFooter( + headerCenter: "&U&\"Arial,Bold\"&14&KFF0000Styled &D &T &A", + footerRight: "Page &P of &N"); + sheet.AddChartFromRange("A1:B3", row: 1, column: 4, widthPixels: 320, heightPixels: 180, type: ExcelChartType.Surface, title: "Unsupported Surface Chart"); + + ExcelChart chart = Assert.Single(sheet.Charts); + Assert.True(chart.TryGetSnapshot(out ExcelChartSnapshot snapshot)); + Assert.Equal(ExcelChartType.Surface, snapshot.ChartType); + + document.Save(false); + + bytes = document.SaveAsPdf(options); + } + + using (PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes))) { + string text = pdf.GetPage(1).Text; + Assert.Contains("Styled", text); + Assert.Contains(DateTime.Now.ToString("d", CultureInfo.CurrentCulture), text); + Assert.Contains("Warnings", text); + Assert.Contains("Page 1 of", text); + Assert.Contains("Alpha", text); + Assert.DoesNotContain("Beta", text); + Assert.DoesNotContain("Unsupported Surface Chart", text); + } + + Assert.Contains(options.Warnings, warning => warning.SheetName == "Warnings" && warning.Feature == "WorksheetHeaderFooterFormatting"); + Assert.Contains(options.Warnings, warning => warning.SheetName == "Warnings" && warning.Feature == "WorksheetRows"); + Assert.Contains(options.Warnings, warning => warning.SheetName == "Warnings" && warning.Feature == "WorksheetChart" && warning.Message.Contains("Surface", StringComparison.Ordinal)); + Assert.All(options.Warnings, warning => Assert.Contains("Warnings", warning.ToString(), StringComparison.Ordinal)); + } + + private static double FindWordStartX(UglyToad.PdfPig.Content.Page page, string word) { + var lines = page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .GroupBy(letter => Math.Round(letter.StartBaseLine.Y, 1)); + + foreach (var line in lines) { + var ordered = line.OrderBy(letter => letter.StartBaseLine.X).ToList(); + string text = string.Concat(ordered.Select(letter => letter.Value)); + int index = text.IndexOf(word, StringComparison.Ordinal); + if (index >= 0) { + return ordered[index].StartBaseLine.X; + } + } + + throw new InvalidOperationException("Could not find word '" + word + "' in rendered PDF text."); + } + + private static double FindWordStartY(UglyToad.PdfPig.Content.Page page, string word) { + var lines = page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .GroupBy(letter => Math.Round(letter.StartBaseLine.Y, 1)); + + foreach (var line in lines) { + var ordered = line.OrderBy(letter => letter.StartBaseLine.X).ToList(); + string text = string.Concat(ordered.Select(letter => letter.Value)); + if (text.IndexOf(word, StringComparison.Ordinal) >= 0) { + return ordered[0].StartBaseLine.Y; + } + } + + throw new InvalidOperationException("Could not find word '" + word + "' in rendered PDF text."); + } + + private static double FindFirstLetterStartX(UglyToad.PdfPig.Content.Page page, string letter) { + double x = page.Letters + .Where(pdfLetter => string.Equals(pdfLetter.Value, letter, StringComparison.Ordinal)) + .Select(pdfLetter => pdfLetter.StartBaseLine.X) + .DefaultIfEmpty(double.NaN) + .First(); + + if (double.IsNaN(x)) { + throw new InvalidOperationException("Could not find letter '" + letter + "' in rendered PDF text."); + } + + return x; + } + + private static byte[] CreateMinimalRgbPng() { + return new byte[] { + 137, 80, 78, 71, 13, 10, 26, 10, + 0, 0, 0, 13, + 73, 72, 68, 82, + 0, 0, 0, 1, + 0, 0, 0, 1, + 8, 2, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 12, + 73, 68, 65, 84, + 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, + 0, 0, 0, 0, + 0, 0, 0, 0, + 73, 69, 78, 68, + 0, 0, 0, 0 + }; + } +} diff --git a/OfficeIMO.Tests/Pdf/PdfComposePageOptionsTests.cs b/OfficeIMO.Tests/Pdf/PdfComposePageOptionsTests.cs index 1b8629f9f..388fa05f1 100644 --- a/OfficeIMO.Tests/Pdf/PdfComposePageOptionsTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfComposePageOptionsTests.cs @@ -100,6 +100,38 @@ public void ComposePage_RejectsInvalidDefaultTextStyleFont() { Assert.Contains("PDF default font must be one of the supported standard PDF fonts.", exception.Message, StringComparison.Ordinal); } + [Fact] + public void PageBackground_RendersBeforePageContent() { + byte[] pdfBytes = PdfDoc.Create() + .Background(PdfColor.FromRgb(240, 248, 255)) + .Paragraph(paragraph => paragraph.Text("DocBackgroundMarker")) + .ToBytes(); + + string content = Encoding.ASCII.GetString(pdfBytes); + int backgroundFill = content.IndexOf("0.941 0.973 1 rg\n0 0 612 792 re f", StringComparison.Ordinal); + int markerText = content.IndexOf("<446F634261636B67726F756E644D61726B6572>", StringComparison.Ordinal); + + Assert.True(backgroundFill >= 0, "Expected the document background to emit a full-page PDF fill."); + Assert.True(markerText > backgroundFill, "Expected the page background to render before text content."); + } + + [Fact] + public void PageBackground_CanBeOverriddenPerComposedPage() { + byte[] pdfBytes = PdfDoc.Create(new PdfOptions { + BackgroundColor = PdfColor.White + }) + .Page(page => page + .Size(300, 400) + .Background(PdfColor.FromRgb(238, 242, 255)) + .Content(content => content.Item(item => item.Paragraph(paragraph => paragraph.Text("PageBackgroundMarker"))))) + .ToBytes(); + + string content = Encoding.ASCII.GetString(pdfBytes); + + Assert.Contains("0.933 0.949 1 rg\n0 0 300 400 re f", content, StringComparison.Ordinal); + Assert.DoesNotContain("1 1 1 rg\n0 0 300 400 re f", content, StringComparison.Ordinal); + } + [Fact] public void ComposeContent_ItemAndSpacerProvideDirectWordLikeFlow() { byte[] pdfBytes = PdfDoc.Create(new PdfOptions { @@ -531,7 +563,7 @@ public void AnnotationDictionaryBuilder_EmitsUriLinkAnnotationsWithEscapedUri() PdfAnnotationDictionaryBuilder.BuildGoToNamedDestinationLinkAnnotation(10, 20.5, 110, 44.25, "Intro(A)", "Jump metadata")); Assert.Equal( - "<< /Type /Annot /Subtype /Widget /FT /Tx /T <506572736F6E2E4E616D65> /V <416461> /DV <416461> /Rect [10 20.5 110 44.25] /F 4 /DA (/Helv 10 Tf 0 g) /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N 12 0 R >> >>\n", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T <506572736F6E2E4E616D65> /V <416461> /DV <416461> /Rect [10 20.5 110 44.25] /F 4 /DA (/Helv 10 Tf 0 0 0 rg) /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N 12 0 R >> >>\n", PdfAnnotationDictionaryBuilder.BuildTextFieldWidgetAnnotation(10, 20.5, 110, 44.25, "Person.Name", "Ada", 10, 12)); Assert.Equal( @@ -539,11 +571,11 @@ public void AnnotationDictionaryBuilder_EmitsUriLinkAnnotationsWithEscapedUri() PdfAnnotationDictionaryBuilder.BuildCheckBoxWidgetAnnotation(10, 20.5, 26, 36.5, "AcceptTerms", true, "Yes", 12, 13)); Assert.Equal( - "<< /Type /Annot /Subtype /Widget /FT /Ch /T <436F756E747279> /V <506F6C616E64> /DV <506F6C616E64> /Opt [ <506F6C616E64> <556E6974656420537461746573> ] /Ff 131072 /Rect [10 20.5 110 44.25] /F 4 /DA (/Helv 10 Tf 0 g) /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N 12 0 R >> >>\n", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T <436F756E747279> /V <506F6C616E64> /DV <506F6C616E64> /Opt [ <506F6C616E64> <556E6974656420537461746573> ] /Ff 131072 /Rect [10 20.5 110 44.25] /F 4 /DA (/Helv 10 Tf 0 0 0 rg) /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N 12 0 R >> >>\n", PdfAnnotationDictionaryBuilder.BuildChoiceFieldWidgetAnnotation(10, 20.5, 110, 44.25, "Country", new[] { "Poland", "United States" }, "Poland", 10, 12, isComboBox: true)); Assert.Equal( - "<< /Type /Annot /Subtype /Widget /FT /Ch /T <436F756E7472696573> /V [<506F6C616E64> <556E6974656420537461746573>] /DV [<506F6C616E64> <556E6974656420537461746573>] /Opt [ <506F6C616E64> <4765726D616E79> <556E6974656420537461746573> ] /Ff 2097152 /Rect [10 20.5 110 70] /F 4 /DA (/Helv 10 Tf 0 g) /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N 12 0 R >> >>\n", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T <436F756E7472696573> /V [<506F6C616E64> <556E6974656420537461746573>] /DV [<506F6C616E64> <556E6974656420537461746573>] /Opt [ <506F6C616E64> <4765726D616E79> <556E6974656420537461746573> ] /Ff 2097152 /Rect [10 20.5 110 70] /F 4 /DA (/Helv 10 Tf 0 0 0 rg) /MK << /BC [0.75 0.75 0.75] /BG [1 1 1] >> /AP << /N 12 0 R >> >>\n", PdfAnnotationDictionaryBuilder.BuildChoiceFieldWidgetAnnotation(10, 20.5, 110, 70, "Countries", new[] { "Poland", "Germany", "United States" }, new[] { "Poland", "United States" }, 10, 12, isComboBox: false, allowsMultipleSelection: true)); Assert.Contains("/T ", PdfAnnotationDictionaryBuilder.BuildTextFieldWidgetAnnotation(10, 20, 110, 44, "名", "Ada", 10, 12), StringComparison.Ordinal); @@ -575,7 +607,7 @@ public void AnnotationDictionaryBuilder_EmitsUriLinkAnnotationsWithEscapedUri() [Fact] public void AcroFormDictionaryBuilder_EmitsFieldsAndTextAppearance() { Assert.Equal( - "<< /Fields [ 4 0 R 5 0 R ] /NeedAppearances true /DR << /Font << /Helv 3 0 R >> >> /DA (/Helv 10 Tf 0 g) >>\n", + "<< /Fields [ 4 0 R 5 0 R ] /NeedAppearances false /DR << /Font << /Helv 3 0 R >> >> /DA (/Helv 10 Tf 0 g) >>\n", PdfAcroFormDictionaryBuilder.BuildAcroFormDictionary(new[] { 4, 5 }, 3)); string content = PdfAcroFormDictionaryBuilder.BuildTextFieldAppearanceContent(120, 20, "Ada", 10); @@ -1218,6 +1250,75 @@ public void HeaderFooterZones_CanConfigureFirstAndEvenPageVariants() { Assert.DoesNotContain("EvenLeft", page3Text, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void HeaderFooterImages_RenderInsideMarginAreasWithoutImplicitFooterText() { + byte[] png = CreateMinimalRgbPng(); + var doc = PdfDoc.Create(new PdfOptions { + PageWidth = 300, + PageHeight = 200, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 40, + MarginBottom = 40, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }) + .Header(header => header.Image(png, 24, 12, PdfAlign.Left).Text("HeaderImageText")) + .Footer(footer => footer.Image(png, 30, 10, PdfAlign.Right)) + .Paragraph(paragraph => paragraph.Text("Header footer image body")); + + byte[] bytes = doc.ToBytes(); + string rawPdf = Encoding.ASCII.GetString(bytes); + + Assert.Contains("24 0 0 12 30 166 cm", rawPdf); + Assert.Contains("30 0 0 10 240 22 cm", rawPdf); + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("HeaderImageText", text); + Assert.Contains("Header footer image body", text); + Assert.DoesNotContain("Page 1", text, StringComparison.Ordinal); + } + + [Fact] + public void HeaderFooterShapes_RenderInsideMarginAreasWithoutImplicitFooterText() { + OfficeShape headerShape = OfficeShape.Rectangle(20, 10); + headerShape.FillColor = OfficeColor.Red; + headerShape.StrokeColor = OfficeColor.Black; + headerShape.StrokeWidth = 1; + + OfficeShape footerShape = OfficeShape.Rectangle(22, 8); + footerShape.FillColor = OfficeColor.Blue; + footerShape.StrokeColor = OfficeColor.Green; + footerShape.StrokeWidth = 1.5; + + var doc = PdfDoc.Create(new PdfOptions { + PageWidth = 300, + PageHeight = 200, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 40, + MarginBottom = 40, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }) + .Header(header => header.Shape(headerShape, PdfAlign.Center).Text("HeaderShapeText")) + .Footer(footer => footer.Shape(footerShape, PdfAlign.Right)) + .Paragraph(paragraph => paragraph.Text("Header footer shape body")); + + byte[] bytes = doc.ToBytes(); + string rawPdf = Encoding.ASCII.GetString(bytes); + + Assert.Contains("1 0 0 rg", rawPdf); + Assert.Contains("0 0 1 rg", rawPdf); + Assert.Contains("0 0.502 0 RG", rawPdf); + Assert.Contains(" re B", rawPdf); + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("HeaderShapeText", text); + Assert.Contains("Header footer shape body", text); + Assert.DoesNotContain("Page 1", text, StringComparison.Ordinal); + } + [Fact] public void DifferentFirstPageHeaderFooter_UsesFirstPageContentThenRunningContent() { var options = new PdfOptions { diff --git a/OfficeIMO.Tests/Pdf/PdfDocBulletListTests.cs b/OfficeIMO.Tests/Pdf/PdfDocBulletListTests.cs index f87c6021d..6eeba9a74 100644 --- a/OfficeIMO.Tests/Pdf/PdfDocBulletListTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfDocBulletListTests.cs @@ -135,6 +135,112 @@ public void ListBlockItemCollections_AreReadOnlySnapshots() { Assert.False(numberedBlock.Items is System.Collections.Generic.List); } + [Fact] + public void RichListBlocks_SnapshotInputRunsBeforeRendering() { + var runs = new System.Collections.Generic.List { + TextRun.Normal("Original"), + TextRun.Bolded(" Bold") + }; + var items = new System.Collections.Generic.List { + new PdfListItem(runs) + }; + + var doc = PdfDoc.Create() + .RichBullets(items) + .RichNumbered(items); + + runs[0] = TextRun.Normal("Mutated"); + runs.Add(TextRun.Normal(" Late")); + items[0] = PdfListItem.Plain("Late item"); + + using var pdf = PdfDocument.Open(new MemoryStream(doc.ToBytes())); + string text = pdf.GetPage(1).Text; + + Assert.Equal(2, CountOccurrences(text, "Original Bold")); + Assert.DoesNotContain("Mutated", text, StringComparison.Ordinal); + Assert.DoesNotContain("Late", text, StringComparison.Ordinal); + } + + [Fact] + public void RichBullets_CanAnchorBookmarksOnListItems() { + byte[] bytes = PdfDoc.Create() + .RichBullets(new[] { + PdfListItem.Plain("Bookmarked bullet", "BulletAnchor"), + PdfListItem.Rich(new[] { TextRun.Normal("Second bookmarked bullet") }, "SecondBulletAnchor") + }) + .Paragraph(p => p.LinkToBookmark("Jump to second bullet", "SecondBulletAnchor", contents: "List bookmark jump")) + .ToBytes(); + + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + var listItems = PdfTextExtractor.ExtractListItemsByPage(bytes) + .SelectMany(page => page.ListItems) + .ToList(); + + Assert.Contains(logical.NamedDestinations, destination => destination.Name == "BulletAnchor"); + Assert.Contains(logical.NamedDestinations, destination => destination.Name == "SecondBulletAnchor"); + Assert.Contains(listItems, item => item.Text == "Bookmarked bullet"); + Assert.Contains(listItems, item => item.Text == "Second bookmarked bullet"); + Assert.Contains(logical.GetLinksByDestinationName("SecondBulletAnchor"), link => link.Contents == "List bookmark jump"); + } + + [Fact] + public void RichListItems_CanRenderExplicitMarkers() { + byte[] bytes = PdfDoc.Create() + .RichNumbered(new[] { + PdfListItem.Plain("Alpha item", marker: "a)"), + PdfListItem.Plain("Roman item", marker: "iv)") + }) + .ToBytes(); + + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + string pageText = pdf.GetPage(1).Text; + + Assert.Contains("a)Alpha item", pageText, StringComparison.Ordinal); + Assert.Contains("iv)Roman item", pageText, StringComparison.Ordinal); + Assert.True(pageText.IndexOf("a)Alpha item", StringComparison.Ordinal) < pageText.IndexOf("iv)Roman item", StringComparison.Ordinal)); + } + + [Fact] + public void RichBullets_RenderScopedRunStylesAndLinks() { + const string linkUri = "https://evotec.xyz/pdf-rich-list"; + byte[] bytes = PdfDoc.Create() + .RichBullets(new[] { + new PdfListItem(new[] { + TextRun.Normal("Plain "), + TextRun.Bolded("Bold"), + TextRun.Normal(" "), + TextRun.Normal("Red", PdfColor.FromRgb(255, 0, 0)), + TextRun.Normal(" "), + TextRun.Normal("Marked", backgroundColor: PdfColor.FromRgb(255, 255, 0)), + TextRun.Normal(" "), + TextRun.Link("Linked", linkUri, contents: "Rich list metadata") + }) + }) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int boldText = content.IndexOf("<426F6C64>", StringComparison.Ordinal); + int redText = content.IndexOf("<526564>", StringComparison.Ordinal); + int markedText = content.IndexOf("<4D61726B6564>", StringComparison.Ordinal); + + using (PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes))) { + Assert.Contains("Plain Bold Red Marked Linked", pdf.GetPage(1).Text, StringComparison.Ordinal); + } + + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes); + PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByUri(linkUri)); + + Assert.True(boldText >= 0, "Expected encoded 'Bold' text in the list content stream."); + Assert.True(redText > boldText, "Expected encoded 'Red' text after the bold list run."); + Assert.True(markedText > redText, "Expected encoded 'Marked' text after the colored list run."); + Assert.True(content.LastIndexOf("/F2 11 Tf", boldText, StringComparison.Ordinal) >= 0, "Expected rich list bold text to use the bold PDF font resource."); + Assert.True(content.LastIndexOf("1 0 0 rg", redText, StringComparison.Ordinal) >= 0, "Expected rich list run color to emit a red PDF fill color."); + Assert.True(content.LastIndexOf("1 1 0 rg", markedText, StringComparison.Ordinal) >= 0, "Expected rich list highlight to emit a yellow PDF fill color."); + Assert.Equal("Rich list metadata", link.Contents); + } + [Fact] public void Bullets_RenderGlyphsWithHangingIndent() { var doc = PdfDoc.Create(); @@ -468,4 +574,15 @@ private static System.Collections.Generic.List GetLineContaining(Page pa .Select(group => group.OrderBy(letter => letter.StartBaseLine.X).ToList()) .First(line => string.Concat(line.Select(letter => letter.Value)).Contains(text, StringComparison.Ordinal)); } + + private static int CountOccurrences(string value, string search) { + int count = 0; + int index = 0; + while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0) { + count++; + index += search.Length; + } + + return count; + } } diff --git a/OfficeIMO.Tests/Pdf/PdfDocImageValidationTests.cs b/OfficeIMO.Tests/Pdf/PdfDocImageValidationTests.cs index eef533499..0ac9a6176 100644 --- a/OfficeIMO.Tests/Pdf/PdfDocImageValidationTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfDocImageValidationTests.cs @@ -69,6 +69,28 @@ public void ImageBlock_SnapshotsImageBytes() { Assert.Equal(1, block.Data[0]); } + [Fact] + public void TableCellImage_RendersInspectableImageInsideCell() { + byte[] png = CreateMinimalRgbPng(); + + byte[] bytes = PdfDoc.Create() + .Table(new[] { + new[] { + PdfTableCell.WithImages( + "Logo", + new[] { new PdfTableCellImage(png, 24, 24) }) + } + }) + .ToBytes(); + + string content = System.Text.Encoding.ASCII.GetString(bytes); + Assert.Contains("/Subtype /Image", content); + Assert.Single(PdfImageExtractor.ExtractImages(bytes)); + + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.Contains("Logo", pdf.GetPage(1).Text); + } + [Fact] public void Options_SnapshotDefaultImageStyle() { var style = new PdfImageStyle { diff --git a/OfficeIMO.Tests/Pdf/PdfDocRasterVisualBaselineTests.cs b/OfficeIMO.Tests/Pdf/PdfDocRasterVisualBaselineTests.cs index 5f31cdd70..b11608993 100644 --- a/OfficeIMO.Tests/Pdf/PdfDocRasterVisualBaselineTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfDocRasterVisualBaselineTests.cs @@ -3,9 +3,15 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; +using OfficeIMO.Excel; +using OfficeIMO.Excel.Pdf; +using OfficeIMO.Word; +using OfficeIMO.Word.Pdf; using OfficeIMO.Drawing; using OfficeIMO.Pdf; +using W = DocumentFormat.OpenXml.Wordprocessing; using Xunit; namespace OfficeIMO.Tests.Pdf; @@ -31,6 +37,166 @@ public void FlowDsl_MatchesPopplerRasterBaseline() { AssertScenarioRasterBaseline("flow-dsl", CreateFlowDsl, pageCount: 3); } + [Fact] + public void NativeWordReport_MatchesPopplerRasterBaseline() { + AssertScenarioRasterBaseline("native-word-report", CreateNativeWordReport); + } + + [Fact] + public void NativeWordDailyLayout_MatchesPopplerRasterBaseline() { + AssertScenarioRasterBaseline("native-word-daily-layout", CreateNativeWordDailyLayout); + } + + [Fact] + public void NativeWordTableCellPictureControl_MatchesPopplerRasterBaseline() { + AssertScenarioRasterBaseline("native-word-table-cell-picture-control", CreateNativeWordTableCellPictureControl); + } + + [Fact] + public void NativeExcelDailyWorkbook_MatchesPopplerRasterBaseline() { + AssertScenarioRasterBaseline("native-excel-daily-workbook", CreateNativeExcelDailyWorkbook, pageCount: 2); + } + + [Fact] + public void NativeWordReport_ExposesBodyCheckBoxesAsAcroFormFields() { + byte[] bytes = CreateNativeWordReport(); + + PdfDocumentInfo info = PdfInspector.Inspect(bytes); + Assert.Collection( + info.FormFields.OrderBy(field => field.Name, StringComparer.Ordinal), + approved => { + Assert.Equal("NativeApproved", approved.Name); + Assert.Equal(PdfFormFieldKind.Button, approved.Kind); + Assert.True(approved.IsCheckBox); + Assert.Equal("Yes", approved.Value); + }, + deferred => { + Assert.Equal("NativeDeferred", deferred.Name); + Assert.Equal(PdfFormFieldKind.Button, deferred.Kind); + Assert.True(deferred.IsCheckBox); + Assert.Equal("Off", deferred.Value); + }, + tableApproved => { + Assert.Equal("NativeTableApproved", tableApproved.Name); + Assert.Equal(PdfFormFieldKind.Button, tableApproved.Kind); + Assert.True(tableApproved.IsCheckBox); + Assert.Equal("Yes", tableApproved.Value); + }); + } + + [Fact] + public void NativeWordReport_ExposesTableListTocAndOutlineSignals() { + byte[] bytes = CreateNativeWordReport(); + + PdfDocumentInfo info = PdfInspector.Inspect(bytes); + Assert.Equal("Native Word PDF Visual Gate", Assert.Single(info.Outlines).Title); + Assert.Contains(info.Outlines[0].Children, outline => outline.Title == "Native proof areas" && outline.Level == 2); + Assert.Contains(info.Outlines[0].Children, outline => outline.Title == "Native evidence table" && outline.Level == 2); + + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + Assert.Contains(logical.Headings, heading => heading.Text == "Native Word PDF Visual Gate"); + Assert.Contains(logical.Headings, heading => heading.Text == "Native proof areas"); + Assert.Contains(logical.Headings, heading => heading.Text == "Native evidence table"); + + var tocLinks = logical.GetLinksByDestinationName("officeimo-heading-native-word-pdf-visual-gate").ToList(); + Assert.NotEmpty(tocLinks); + Assert.All(tocLinks, link => Assert.Equal("Table of contents: Native Word PDF Visual Gate", link.Contents)); + + var listItems = PdfTextExtractor.ExtractListItemsByPage(bytes) + .SelectMany(page => page.ListItems) + .ToList(); + Assert.Contains(listItems, item => item.Text == "Native list mapping keeps markers and text aligned."); + Assert.Contains(listItems, item => item.Marker == "1" && item.Text == "Generated TOC appears before content."); + + using UglyToad.PdfPig.PdfDocument pdf = UglyToad.PdfPig.PdfDocument.Open(bytes); + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Table of Contents", text); + Assert.Contains("AreaNative statusEvidence", text); + Assert.Contains("TablesPartialstyle and borders", text); + } + + [Fact] + public void NativeWordDailyLayout_ExposesColumnsLinksTocAndLayoutSignals() { + byte[] bytes = CreateNativeWordDailyLayout(); + + PdfDocumentInfo info = PdfInspector.Inspect(bytes); + Assert.Equal(1, info.PageCount); + Assert.Contains(info.Outlines, outline => outline.Title == "Daily Word Layout Gate"); + Assert.Contains(info.LinkUris, uri => uri == "https://evotec.xyz/native-daily-layout"); + Assert.Contains(info.LinkUris, uri => uri == "https://officeimo.net/"); + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.918 0.957 1 rg", rawPdf, StringComparison.Ordinal); + + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + Assert.Contains(logical.Headings, heading => heading.Text == "Daily Word Layout Gate"); + Assert.Contains(logical.Headings, heading => heading.Text == "Column narrative"); + Assert.Contains(logical.Headings, heading => heading.Text == "Column evidence"); + Assert.NotEmpty(logical.GetLinksByDestinationName("officeimo-heading-daily-word-layout-gate")); + + using UglyToad.PdfPig.PdfDocument pdf = UglyToad.PdfPig.PdfDocument.Open(bytes); + var page = pdf.GetPage(1); + string pageText = page.Text; + Assert.Contains("Table of Contents", pageText); + Assert.Contains("Column narrative", pageText); + Assert.Contains("Column evidence", pageText); + Assert.Contains("Inline column break", pageText); + Assert.Contains("separator", pageText); + + var words = page.GetWords().ToList(); + double narrativeX = words.First(word => word.Text == "narrative").BoundingBox.Left; + double evidenceX = words.First(word => word.Text == "evidence").BoundingBox.Left; + Assert.True(evidenceX > narrativeX + 250D, $"Expected evidence heading to render in the second Word section column. Narrative x: {narrativeX:0.##}, evidence x: {evidenceX:0.##}."); + } + + [Fact] + public void NativeExcelDailyWorkbook_ExposesSheetsLinksImagesAndLayoutSignals() { + byte[] bytes = CreateNativeExcelDailyWorkbook(); + + PdfDocumentInfo info = PdfInspector.Inspect(bytes); + Assert.Equal(2, info.PageCount); + Assert.Contains(info.Outlines, outline => outline.Title == "Summary"); + Assert.Contains(info.Outlines, outline => outline.Title == "Details"); + Assert.Contains(info.LinkUris, uri => uri == "https://officeimo.net/excel-pdf"); + + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + Assert.Contains(logical.NamedDestinations, destination => destination.Name.Contains("summary", StringComparison.OrdinalIgnoreCase)); + Assert.Single(logical.NamedDestinations, destination => string.Equals(destination.Name, "excel-sheet-2-details", StringComparison.Ordinal)); + PdfNamedDestination detailsCellDestination = Assert.Single(logical.NamedDestinations, destination => string.Equals(destination.Name, "excel-sheet-2-details-a1", StringComparison.Ordinal)); + PdfLogicalLinkAnnotation detailsLink = Assert.Single(logical.GetLinksByDestinationName(detailsCellDestination.Name)); + Assert.Equal("Open Details", detailsLink.Contents); + + var images = PdfImageExtractor.ExtractImages(bytes); + Assert.True(images.Count >= 2, "Expected worksheet body and header images to survive Excel-to-PDF export."); + + using UglyToad.PdfPig.PdfDocument pdf = UglyToad.PdfPig.PdfDocument.Open(bytes); + UglyToad.PdfPig.Content.Page summaryPage = pdf.GetPage(1); + string summaryText = summaryPage.Text; + Assert.Contains("Daily Excel PDF Gate", summaryText); + Assert.Contains("Revenue", summaryText); + Assert.Contains("Revenue Chart", summaryText); + Assert.Contains("Actual", summaryText); + Assert.Contains("Target", summaryText); + Assert.Contains("$12,345.60", summaryText); + Assert.Contains("25.7%", summaryText); + Assert.Contains("Open Details", summaryText); + Assert.DoesNotContain("HiddenRowValue", summaryText); + Assert.DoesNotContain("HiddenColumnValue", summaryText); + + double metricX = FindWordStartX(summaryPage, "Metric"); + double statusX = FindWordStartX(summaryPage, "Status"); + Assert.True(statusX > metricX + 160D, $"Expected explicit worksheet column widths to make the status column visibly farther right. Metric x: {metricX:0.##}, Status x: {statusX:0.##}."); + + string detailsText = pdf.GetPage(2).Text; + Assert.Contains("Details", detailsText); + Assert.Contains("Details Target", detailsText); + } + [Theory] [InlineData("hello-world")] [InlineData("core-layout")] @@ -161,6 +327,344 @@ private static byte[] CreateCoreScenario(string scenarioName) { } } + private static byte[] CreateNativeWordReport() { + string workDir = Path.Combine(Path.GetTempPath(), "OfficeIMO.WordNativePdfRaster", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + string docPath = Path.Combine(workDir, "native-word-report.docx"); + string pdfPath = Path.Combine(workDir, "native-word-report.pdf"); + string logoPath = Path.Combine(GetTestsProjectRoot(), "Images", "EvotecLogo.png"); + + try { + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordParagraph headerLogo = document.Sections[0].Header.Default!.AddParagraph(); + headerLogo.ParagraphAlignment = W.JustificationValues.Right; + headerLogo.AddImage(logoPath, 48, 24); + + document.AddTableOfContent(); + document.AddParagraph("Native Word PDF Visual Gate").SetStyle(WordParagraphStyles.Heading1); + + WordParagraph summary = document.AddParagraph("This document is generated as DOCX first, then rendered with the first-party OfficeIMO PDF engine."); + summary.SetFontSize(10); + + document.AddParagraph("Native proof areas").SetStyle(WordParagraphStyles.Heading2); + + WordParagraph styled = document.AddParagraph(); + styled.AddText("Scoped runs: "); + styled.AddText("large blue").SetFontSize(15).ColorHex = "1f4e79"; + styled.AddText(", "); + styled.AddText("highlighted").SetHighlight(W.HighlightColorValues.Yellow); + styled.AddText(", and restored default text."); + + WordParagraph panel = document.AddParagraph("Shaded paragraph with uniform Word borders mapped through the native PDF path."); + panel.ShadingFillColorHex = "e6f2ff"; + panel.Borders.TopStyle = W.BorderValues.Single; + panel.Borders.BottomStyle = W.BorderValues.Single; + panel.Borders.LeftStyle = W.BorderValues.Single; + panel.Borders.RightStyle = W.BorderValues.Single; + panel.Borders.TopColorHex = "336699"; + panel.Borders.BottomColorHex = "336699"; + panel.Borders.LeftColorHex = "336699"; + panel.Borders.RightColorHex = "336699"; + panel.Borders.TopSize = 8; + panel.Borders.BottomSize = 8; + panel.Borders.LeftSize = 8; + panel.Borders.RightSize = 8; + + WordList bullets = document.AddList(WordListStyle.Bulleted); + bullets.AddItem("Native list mapping keeps markers and text aligned."); + bullets.AddItem("The visual gate catches rhythm drift before QuestPDF removal."); + + WordList steps = document.AddCustomList(); + steps.Numbering.AddLevel(new WordListLevel(WordListLevelKind.DecimalDot)); + steps.AddItem("Generated TOC appears before content."); + steps.AddItem("Tables and lists remain aligned."); + + document.AddParagraph("Approved native checkbox").AddCheckBox(true, "Native Approved", "NativeApproved"); + document.AddParagraph("Deferred native checkbox").AddCheckBox(false, "Native Deferred", "NativeDeferred"); + + document.AddParagraph("Native evidence table").SetStyle(WordParagraphStyles.Heading2); + + WordTable table = document.AddTable(4, 3); + table.Style = WordTableStyle.GridTable1LightAccent1; + table.Rows[0].Cells[0].Paragraphs[0].Text = "Area"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "Native status"; + table.Rows[0].Cells[2].Paragraphs[0].Text = "Evidence"; + table.Rows[1].Cells[0].Paragraphs[0].Text = "Runs"; + table.Rows[1].Cells[1].Paragraphs[0].Text = "Improving"; + table.Rows[1].Cells[2].Paragraphs[0].Text = "color, size, highlight"; + table.Rows[2].Cells[0].Paragraphs[0].Text = "Tables"; + table.Rows[2].Cells[1].Paragraphs[0].Text = "Partial"; + table.Rows[2].Cells[2].Paragraphs[0].Text = "style and borders"; + table.Rows[3].Cells[0].Paragraphs[0].Text = "Forms"; + table.Rows[3].Cells[1].Paragraphs[0].Text = "Improving"; + table.Rows[3].Cells[2].Paragraphs[0].Text = "cell checkbox"; + table.Rows[3].Cells[2].Paragraphs[0].AddCheckBox(true, "Native Table Approved", "NativeTableApproved"); + table.RepeatHeaderRowAtTheTopOfEachPage = true; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(612, 792), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(36) + }); + } + + return File.ReadAllBytes(pdfPath); + } finally { + TryDeleteDirectory(workDir); + } + } + + private static byte[] CreateNativeWordDailyLayout() { + string workDir = Path.Combine(Path.GetTempPath(), "OfficeIMO.WordNativePdfDailyLayout", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + string docPath = Path.Combine(workDir, "native-word-daily-layout.docx"); + string pdfPath = Path.Combine(workDir, "native-word-daily-layout.pdf"); + string logoPath = Path.Combine(GetTestsProjectRoot(), "Images", "EvotecLogo.png"); + + try { + using (WordDocument document = WordDocument.Create(docPath)) { + document.Settings.FontFamily = "Calibri"; + document.Background.SetColorHex("EAF4FF"); + WordSection section = document.Sections[0]; + section.Margins.LeftCentimeters = 1.6; + section.Margins.RightCentimeters = 1.4; + section.Margins.TopCentimeters = 1.4; + section.Margins.BottomCentimeters = 1.6; + section.ColumnCount = 2; + section.ColumnsSpace = 540; + section.HasColumnSeparator = true; + + document.AddHeadersAndFooters(); + WordParagraph header = document.Sections[0].Header.Default!.AddParagraph(); + header.ParagraphAlignment = W.JustificationValues.Right; + header.AddText("Daily layout gate"); + header.AddImage(logoPath, 42, 20); + document.Sections[0].Footer.Default!.AddParagraph("OfficeIMO native Word layout proof"); + + document.AddTableOfContent(); + document.AddParagraph("Daily Word Layout Gate").SetStyle(WordParagraphStyles.Heading1); + + WordParagraph intro = document.AddParagraph("This Word-origin fixture combines margins, section columns, fonts, colors, links, images, lists, a TOC, and a table on one page."); + intro.SetFontSize(9); + + WordParagraph leftHeading = document.AddParagraph("Column narrative"); + leftHeading.SetStyle(WordParagraphStyles.Heading2); + leftHeading.SetColorHex("#1f4e79"); + + WordParagraph rich = document.AddParagraph(); + rich.AddText("Styled runs: "); + rich.AddText("Calibri default").SetFontFamily("Calibri"); + rich.AddText(", "); + rich.AddText("Courier note").SetFontFamily("Courier New").SetFontSize(8); + rich.AddText(", "); + rich.AddText("blue emphasis").SetFontSize(11).ColorHex = "1f4e79"; + rich.AddText(", and "); + rich.AddHyperLink("external link", new Uri("https://evotec.xyz/native-daily-layout"), addStyle: true, tooltip: "Native daily layout link"); + rich.AddText("."); + + WordParagraph shaded = document.AddParagraph("Shaded paragraph and side borders protect report-style callouts."); + shaded.ShadingFillColorHex = "e6f2ff"; + shaded.Borders.LeftStyle = W.BorderValues.Single; + shaded.Borders.LeftColorHex = "1f4e79"; + shaded.Borders.LeftSize = 12; + shaded.Borders.BottomStyle = W.BorderValues.Single; + shaded.Borders.BottomColorHex = "b7c9d9"; + shaded.Borders.BottomSize = 4; + + WordList bullets = document.AddList(WordListStyle.Bulleted); + bullets.AddItem("Bullet markers stay inside the first Word column."); + bullets.AddItem("Wrapped list text keeps readable spacing."); + + WordList steps = document.AddCustomList(); + steps.Numbering.AddLevel(new WordListLevel(WordListLevelKind.DecimalDot)); + steps.AddItem("Collect daily document patterns."); + steps.AddItem("Render through OfficeIMO.Pdf."); + + document.AddParagraph().AddImage(logoPath, 54, 26); + WordParagraph columnHandoff = document.AddParagraph("Inline column break keeps this text in the first column."); + columnHandoff.AddBreak(W.BreakValues.Column); + columnHandoff.AddText("Right column starts here."); + + WordParagraph rightHeading = document.AddParagraph("Column evidence"); + rightHeading.SetStyle(WordParagraphStyles.Heading2); + rightHeading.SetColorHex("#2f6f3e"); + + WordTable table = document.AddTable(4, 3); + table.Style = WordTableStyle.GridTable1LightAccent1; + table.Rows[0].Cells[0].Paragraphs[0].Text = "Area"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "Mapped"; + table.Rows[0].Cells[2].Paragraphs[0].Text = "Visual"; + table.Rows[1].Cells[0].Paragraphs[0].Text = "Margins"; + table.Rows[1].Cells[1].Paragraphs[0].Text = "Yes"; + table.Rows[1].Cells[2].Paragraphs[0].Text = "left edge"; + table.Rows[2].Cells[0].Paragraphs[0].Text = "Columns"; + table.Rows[2].Cells[1].Paragraphs[0].Text = "Yes"; + table.Rows[2].Cells[2].Paragraphs[0].Text = "separator"; + table.Rows[3].Cells[0].Paragraphs[0].Text = "Links"; + table.Rows[3].Cells[1].Paragraphs[0].AddHyperLink("Yes", new Uri("https://officeimo.net/"), addStyle: true, tooltip: "Native table link"); + table.Rows[3].Cells[2].Paragraphs[0].Text = "annotation"; + + WordParagraph closing = document.AddParagraph("Footer, header image, TOC link, section separator, and table borders should all survive the native PDF path."); + closing.SetFontSize(8); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PageSize(612, 792) + }); + } + + return File.ReadAllBytes(pdfPath); + } finally { + TryDeleteDirectory(workDir); + } + } + + private static byte[] CreateNativeWordTableCellPictureControl() { + string workDir = Path.Combine(Path.GetTempPath(), "OfficeIMO.WordNativePdfRaster", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + string docPath = Path.Combine(workDir, "native-word-table-cell-picture-control.docx"); + string pdfPath = Path.Combine(workDir, "native-word-table-cell-picture-control.pdf"); + string logoPath = Path.Combine(GetTestsProjectRoot(), "Images", "EvotecLogo.png"); + + try { + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native table-cell picture control").SetStyle(WordParagraphStyles.Heading1); + document.AddParagraph("The logo below starts as a Word picture content control inside a table cell and renders through OfficeIMO.Pdf."); + + WordTable table = document.AddTable(2, 2, WordTableStyle.TableGrid); + table.Rows[0].Cells[0].Paragraphs[0].Text = "Control"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "Rendered evidence"; + table.Rows[1].Cells[0].Paragraphs[0].Text = "Picture content control"; + WordParagraph pictureCell = table.Rows[1].Cells[1].Paragraphs[0]; + pictureCell.Text = "Table-cell logo"; + pictureCell.ParagraphAlignment = W.JustificationValues.Center; + pictureCell.AddPictureControl(logoPath, 72, 36, "Table Cell Logo", "TableCellLogo"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PageSize(612, 360), + OfficeIMOMargins = PageMargins.Uniform(36) + }); + } + + return File.ReadAllBytes(pdfPath); + } finally { + TryDeleteDirectory(workDir); + } + } + + private static byte[] CreateNativeExcelDailyWorkbook() { + string workDir = Path.Combine(Path.GetTempPath(), "OfficeIMO.ExcelNativePdfRaster", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + string workbookPath = Path.Combine(workDir, "native-excel-daily-workbook.xlsx"); + string pdfPath = Path.Combine(workDir, "native-excel-daily-workbook.pdf"); + byte[] logoBytes = File.ReadAllBytes(Path.Combine(GetTestsProjectRoot(), "Images", "EvotecLogo.png")); + + try { + using (ExcelDocument document = ExcelDocument.Create(workbookPath)) { + ExcelSheet summary = document.AddWorkSheet("Summary"); + summary.CellAt(1, 1).SetValue("Daily Excel PDF Gate").SetBold().SetFontColor("1F4E79").SetFillColor("DDEEFF"); + summary.MergeRange("A1:B1"); + summary.CellAt(2, 1).SetValue("Metric").SetBold().SetFillColor("E6F2FF"); + summary.CellAt(2, 2).SetValue("Value").SetBold().SetFillColor("E6F2FF"); + summary.CellAt(2, 3).SetValue("Status").SetBold().SetFillColor("E6F2FF"); + summary.Cell(3, 1, "Revenue"); + summary.CellAt(3, 2).SetValue(12345.6).Currency(2, System.Globalization.CultureInfo.GetCultureInfo("en-US")); + summary.CellAt(3, 3).SetValue("On track").SetFontColor("2F6F3E"); + summary.Cell(4, 1, "Margin"); + summary.CellAt(4, 2).SetValue(0.257).Percent(1); + summary.SetHyperlink(4, 3, "https://officeimo.net/excel-pdf", display: "External Link"); + summary.Cell(5, 1, "Drilldown"); + summary.SetInternalLink(5, 2, "Details!A1", display: "Open Details"); + summary.Cell(6, 1, "Visual image"); + summary.Cell(6, 3, "logo above grid"); + summary.AddImage(6, 2, logoBytes, "image/png", widthPixels: 64, heightPixels: 28, name: "Summary Logo", altText: "Summary visual proof"); + summary.Cell(8, 1, "HiddenRowValue"); + summary.Cell(10, 1, "Month"); + summary.Cell(10, 2, "Actual"); + summary.Cell(10, 3, "Target"); + summary.Cell(11, 1, "Jan"); + summary.Cell(11, 2, 12); + summary.Cell(11, 3, 10); + summary.Cell(12, 1, "Feb"); + summary.Cell(12, 2, 18); + summary.Cell(12, 3, 16); + summary.Cell(13, 1, "Mar"); + summary.Cell(13, 2, 24); + summary.Cell(13, 3, 20); + summary.AddChartFromRange("A10:C13", row: 7, column: 1, widthPixels: 300, heightPixels: 140, type: ExcelChartType.ColumnClustered, title: "Revenue Chart"); + summary.SetColumnWidth(1, 16); + summary.SetColumnWidth(2, 18); + summary.SetColumnWidth(3, 28); + summary.SetRowHeight(1, 28); + summary.SetRowHeight(6, 32); + summary.SetColumnHidden(4, true); + summary.Cell(2, 4, "HiddenColumnValue"); + summary.SetRowHidden(8, true); + summary.CellBorder(2, 1, DocumentFormat.OpenXml.Spreadsheet.BorderStyleValues.Thin, "445566"); + summary.CellBorder(2, 2, DocumentFormat.OpenXml.Spreadsheet.BorderStyleValues.Thin, "445566"); + summary.CellBorder(2, 3, DocumentFormat.OpenXml.Spreadsheet.BorderStyleValues.Thin, "445566"); + summary.CellAlign(3, 2, DocumentFormat.OpenXml.Spreadsheet.HorizontalAlignmentValues.Right); + summary.CellAlign(4, 2, DocumentFormat.OpenXml.Spreadsheet.HorizontalAlignmentValues.Right); + summary.SetHeaderFooter( + headerLeft: "Daily Excel", + headerCenter: "Workbook &A", + headerRight: "Page &P of &N", + footerLeft: "OfficeIMO Excel PDF", + footerRight: "Visual baseline"); + summary.SetHeaderImage(HeaderFooterPosition.Left, logoBytes, "image/png", widthPoints: 28, heightPoints: 14); + summary.SetOrientation(ExcelPageOrientation.Landscape); + summary.SetMargins(left: 0.35, right: 0.35, top: 0.55, bottom: 0.55); + + ExcelSheet details = document.AddWorkSheet("Details"); + details.CellAt(1, 1).SetValue("Details Target").SetBold().SetFillColor("E2F0D9"); + details.Cell(2, 1, "Owner"); + details.Cell(2, 2, "OfficeIMO"); + details.Cell(3, 1, "Status"); + details.Cell(3, 2, "Linked sheet destination"); + details.SetColumnWidth(1, 16); + details.SetColumnWidth(2, 30); + + document.SetPrintArea(summary, "A1:C7"); + document.SetPrintTitles(summary, firstRow: 2, lastRow: 2, firstCol: null, lastCol: null); + document.SetPrintArea(details, "A1:B3"); + document.Save(false); + + document.SaveAsPdf(pdfPath, new ExcelPdfSaveOptions { + IncludeSheetHeadings = true, + HeaderRowCount = 2, + PageSize = new PageSize(792, 612), + Margins = PageMargins.FromInches(0.35, 0.55, 0.35, 0.55) + }); + } + + return File.ReadAllBytes(pdfPath); + } finally { + TryDeleteDirectory(workDir); + } + } + + private static double FindWordStartX(UglyToad.PdfPig.Content.Page page, string word) { + var lines = page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .GroupBy(letter => Math.Round(letter.StartBaseLine.Y, 1)); + + foreach (var line in lines) { + var ordered = line.OrderBy(letter => letter.StartBaseLine.X).ToList(); + string text = string.Concat(ordered.Select(letter => letter.Value)); + int index = text.IndexOf(word, StringComparison.Ordinal); + if (index >= 0) { + return ordered[index].StartBaseLine.X; + } + } + + throw new InvalidOperationException("Could not find word '" + word + "' in rendered PDF text."); + } + private static byte[] CreateHelloWorld() { return PdfDoc.Create(new PdfOptions { DefaultFont = PdfStandardFont.Helvetica, diff --git a/OfficeIMO.Tests/Pdf/PdfDocVisualQualityTests.cs b/OfficeIMO.Tests/Pdf/PdfDocVisualQualityTests.cs index bb2606a28..8f2a42de2 100644 --- a/OfficeIMO.Tests/Pdf/PdfDocVisualQualityTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfDocVisualQualityTests.cs @@ -219,6 +219,130 @@ public void DefaultOptions_UseProportionalHelveticaForPlainDocuments() { Assert.DoesNotContain("/BaseFont /Courier", content); } + [Fact] + public void RichParagraph_ResetColor_ReturnsToDefaultTextColor() { + byte[] bytes = PdfDoc.Create() + .Paragraph(p => p + .Text("Before ") + .Color(new PdfColor(1, 0, 0)) + .Text("Red") + .ResetColor() + .Text("After")) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int redText = content.IndexOf("<526564>", StringComparison.Ordinal); + int afterText = content.IndexOf("<4166746572>", StringComparison.Ordinal); + + Assert.True(redText >= 0, "Expected encoded 'Red' text in the generated PDF content stream."); + Assert.True(afterText > redText, "Expected encoded 'After' text after the red run."); + + int redColorBeforeRed = content.LastIndexOf("1 0 0 rg", redText, StringComparison.Ordinal); + int blackColorBeforeAfter = content.LastIndexOf("0 0 0 rg", afterText, StringComparison.Ordinal); + int redColorBeforeAfter = content.LastIndexOf("1 0 0 rg", afterText, StringComparison.Ordinal); + + Assert.True(redColorBeforeRed >= 0, "Expected the red run to emit a red fill color."); + Assert.True(blackColorBeforeAfter > redText, "Expected ResetColor to emit black/default fill color before the following run."); + Assert.True(redColorBeforeAfter < blackColorBeforeAfter, "Expected the following run not to inherit the previous red fill color."); + } + + [Fact] + public void RichParagraph_FontSize_AppliesOnlyToScopedRuns() { + byte[] bytes = PdfDoc.Create(new PdfOptions { + DefaultFontSize = 11 + }) + .Paragraph(p => p + .Text("Small") + .FontSize(18) + .Text("Large") + .ResetFontSize() + .Text("Normal")) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int smallText = content.IndexOf("<536D616C6C>", StringComparison.Ordinal); + int largeText = content.IndexOf("<4C61726765>", StringComparison.Ordinal); + int normalText = content.IndexOf("<4E6F726D616C>", StringComparison.Ordinal); + + Assert.True(smallText >= 0, "Expected encoded 'Small' text in the generated PDF content stream."); + Assert.True(largeText > smallText, "Expected encoded 'Large' text after the default-sized run."); + Assert.True(normalText > largeText, "Expected encoded 'Normal' text after the large run."); + + int defaultSizeBeforeSmall = content.LastIndexOf("/F1 11 Tf", smallText, StringComparison.Ordinal); + int largeSizeBeforeLarge = content.LastIndexOf("/F1 18 Tf", largeText, StringComparison.Ordinal); + int defaultSizeBeforeNormal = content.LastIndexOf("/F1 11 Tf", normalText, StringComparison.Ordinal); + + Assert.True(defaultSizeBeforeSmall >= 0, "Expected the first run to use the paragraph/default font size."); + Assert.True(largeSizeBeforeLarge > smallText, "Expected FontSize(18) to emit an 18-point font before the scoped run."); + Assert.True(defaultSizeBeforeNormal > largeText, "Expected ResetFontSize to restore the paragraph/default font size for later runs."); + } + + [Fact] + public void RichParagraph_BackgroundColor_RendersBehindScopedRuns() { + byte[] bytes = PdfDoc.Create() + .Paragraph(p => p + .Text("Before ") + .BackgroundColor(PdfColor.FromRgb(255, 255, 0)) + .Text("Marked") + .ResetBackgroundColor() + .Text("After")) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int markedText = content.IndexOf("<4D61726B6564>", StringComparison.Ordinal); + int afterText = content.IndexOf("<4166746572>", StringComparison.Ordinal); + + Assert.True(markedText >= 0, "Expected encoded 'Marked' text in the generated PDF content stream."); + Assert.True(afterText > markedText, "Expected encoded 'After' text after the highlighted run."); + + int highlightFill = content.LastIndexOf("1 1 0 rg", markedText, StringComparison.Ordinal); + int highlightRect = content.LastIndexOf(" re f", markedText, StringComparison.Ordinal); + + Assert.True(highlightFill >= 0, "Expected the highlighted run to emit a yellow fill color."); + Assert.True(highlightRect > highlightFill, "Expected the highlighted run to emit a filled rectangle before the text."); + Assert.Single(Regex.Matches(content, "1 1 0 rg").Cast()); + } + + [Fact] + public void Table_RichTextCell_RendersScopedRunStyles() { + byte[] bytes = PdfDoc.Create(new PdfOptions { + DefaultFontSize = 11 + }) + .Table(new[] { + new[] { + PdfTableCell.RichTextCell(new[] { + TextRun.Normal("Plain "), + new TextRun("CellRed", color: PdfColor.FromRgb(255, 0, 0)), + TextRun.Normal(" "), + TextRun.Bolded("CellBold"), + TextRun.Normal(" "), + TextRun.Normal("CellMarked", backgroundColor: PdfColor.FromRgb(255, 255, 0)), + TextRun.Normal(" "), + TextRun.Normal("CellLarge", fontSize: 18) + }) + } + }, style: new PdfTableStyle { + HeaderRowCount = 0 + }) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int redText = content.IndexOf("<43656C6C526564>", StringComparison.Ordinal); + int boldText = content.IndexOf("<43656C6C426F6C64>", StringComparison.Ordinal); + int markedText = content.IndexOf("<43656C6C4D61726B6564>", StringComparison.Ordinal); + int largeText = content.IndexOf("<43656C6C4C61726765>", StringComparison.Ordinal); + + Assert.True(redText >= 0, "Expected encoded rich table cell red text in the PDF content stream."); + Assert.True(boldText > redText, "Expected encoded bold table cell text after the red run."); + Assert.True(markedText > boldText, "Expected encoded highlighted table cell text after the bold run."); + Assert.True(largeText > markedText, "Expected encoded large table cell text after the highlighted run."); + + Assert.True(content.LastIndexOf("1 0 0 rg", redText, StringComparison.Ordinal) >= 0, "Expected rich table cell color to emit a red fill color."); + Assert.True(content.LastIndexOf("/F2 11 Tf", boldText, StringComparison.Ordinal) >= 0, "Expected rich table cell bold run to use the bold font resource."); + Assert.True(content.LastIndexOf("1 1 0 rg", markedText, StringComparison.Ordinal) >= 0, "Expected rich table cell highlight to emit a yellow fill color."); + Assert.True(content.LastIndexOf("/F1 18 Tf", largeText, StringComparison.Ordinal) >= 0, "Expected rich table cell font size to emit an 18-point run."); + } + [Fact] public void RichText_RejectsNullRunTextBeforeRendering() { Assert.Throws(() => @@ -455,6 +579,8 @@ public void Options_SnapshotDefaultHeadingStyles() { SpacingBefore = 4, SpacingAfter = 12, Color = PdfColor.FromRgb(10, 20, 30), + Bold = false, + ApplySpacingBeforeAtTop = true, KeepWithNext = false }; var styles = new PdfHeadingStyles { @@ -482,8 +608,12 @@ public void Options_SnapshotDefaultHeadingStyles() { Assert.Equal(4, options.DefaultHeadingStyles.Level1.SpacingBefore); Assert.Equal(12, options.DefaultHeadingStyles.Level1.SpacingAfter); Assert.Equal(PdfColor.FromRgb(10, 20, 30), options.DefaultHeadingStyles.Level1.Color); + Assert.False(options.DefaultHeadingStyles.Level1.Bold); + Assert.True(options.DefaultHeadingStyles.Level1.ApplySpacingBeforeAtTop); Assert.False(options.DefaultHeadingStyles.Level1.KeepWithNext); Assert.Equal(16, clone.DefaultHeadingStyles!.Level1!.FontSize); + Assert.False(clone.DefaultHeadingStyles.Level1.Bold); + Assert.True(clone.DefaultHeadingStyles.Level1.ApplySpacingBeforeAtTop); } [Fact] @@ -530,6 +660,65 @@ public void Options_SnapshotDefaultPanelStyle() { Assert.True(clone.DefaultPanelStyle.KeepWithNext); } + [Fact] + public void PanelParagraph_RendersAndSnapshotsSideSpecificPanelBorders() { + PdfColor red = PdfColor.FromRgb(255, 0, 0); + PdfColor blue = PdfColor.FromRgb(0, 0, 255); + var style = new PanelStyle { + Background = PdfColor.FromRgb(245, 245, 245), + TopBorder = new PdfPanelBorder { + Color = red, + Width = 2 + }, + LeftBorder = new PdfPanelBorder { + Color = blue, + Width = 1.5 + }, + PaddingX = 8, + PaddingY = 6, + SpacingAfter = 0 + }; + var options = new PdfOptions { + PageWidth = 260, + PageHeight = 180, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultPanelStyle = style + }; + + style.TopBorder = new PdfPanelBorder { + Color = PdfColor.FromRgb(0, 128, 0), + Width = 4 + }; + PanelStyle readback = options.DefaultPanelStyle!; + readback.LeftBorder = new PdfPanelBorder { + Color = PdfColor.Black, + Width = 3 + }; + + PdfOptions clone = options.Clone(); + byte[] bytes = PdfDoc.Create(options) + .PanelParagraph(p => p.Text("PanelSideBorders")) + .ToBytes(); + + string raw = Encoding.ASCII.GetString(bytes); + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + + Assert.Equal(red, options.DefaultPanelStyle!.TopBorder!.Color); + Assert.Equal(2, options.DefaultPanelStyle.TopBorder.Width); + Assert.Equal(blue, options.DefaultPanelStyle.LeftBorder!.Color); + Assert.Equal(1.5, options.DefaultPanelStyle.LeftBorder.Width); + Assert.Equal(red, clone.DefaultPanelStyle!.TopBorder!.Color); + Assert.Equal(blue, clone.DefaultPanelStyle.LeftBorder!.Color); + Assert.Contains("PanelSideBorders", pdf.GetPage(1).Text); + Assert.Contains("1 0 0 RG", raw); + Assert.Contains("2 w", raw); + Assert.Contains("0 0 1 RG", raw); + Assert.Contains("1.5 w", raw); + } + [Fact] public void Options_SnapshotDefaultHorizontalRuleStyle() { var style = new PdfHorizontalRuleStyle { @@ -800,6 +989,32 @@ public void PdfDoc_DefaultHeadingStyleAppliesToFollowingHeadingsAndSnapshotsInpu Assert.Contains("0.039 0.078 0.118 rg", rawPdf, StringComparison.Ordinal); } + [Fact] + public void HeadingStyle_BoldFalse_UsesNormalFontResource() { + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 300, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }) + .H1("RegularHeading", style: new PdfHeadingStyle { + FontSize = 14, + Bold = false + }) + .ToBytes(); + + string rawPdf = Encoding.ASCII.GetString(bytes); + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + + Assert.Contains("RegularHeading", pdf.GetPage(1).Text); + Assert.Contains("/BaseFont /Helvetica", rawPdf, StringComparison.Ordinal); + Assert.DoesNotContain("/Helvetica-Bold", rawPdf, StringComparison.Ordinal); + } + [Fact] public void Heading_UsesConfiguredSpacingBeforeAndAfter() { var options = new PdfOptions { @@ -891,6 +1106,97 @@ public void Heading_SuppressesSpacingBeforeAtPageTop() { Assert.InRange(Math.Abs(defaultTopY - spacedTopY), 0, 1.5); } + [Fact] + public void Heading_CanApplySpacingBeforeAtPageTop() { + var options = new PdfOptions { + PageWidth = 320, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }; + var defaultStyle = new PdfHeadingStyle { + FontSize = 12, + LineHeight = 1, + SpacingBefore = 0, + SpacingAfter = 0 + }; + var spacedStyle = new PdfHeadingStyle { + FontSize = 12, + LineHeight = 1, + SpacingBefore = 28, + SpacingAfter = 0, + ApplySpacingBeforeAtTop = true + }; + + byte[] defaultBytes = PdfDoc.Create(options) + .H2("TopHeadingMarker", style: defaultStyle) + .ToBytes(); + byte[] spacedBytes = PdfDoc.Create(options) + .H2("TopHeadingMarker", style: spacedStyle) + .ToBytes(); + + using var defaultPdf = PdfDocument.Open(new MemoryStream(defaultBytes)); + using var spacedPdf = PdfDocument.Open(new MemoryStream(spacedBytes)); + + double defaultTopY = FindWordStartY(defaultPdf.GetPage(1), "TopHeadingMarker"); + double spacedTopY = FindWordStartY(spacedPdf.GetPage(1), "TopHeadingMarker"); + + Assert.True(defaultTopY - spacedTopY >= 26, $"Expected opt-in top spacing to move heading text down. Default y: {defaultTopY:0.##}, spaced y: {spacedTopY:0.##}."); + } + + [Fact] + public void Heading_CanApplySpacingBeforeAfterAutomaticPageBreak() { + var options = new PdfOptions { + PageWidth = 320, + PageHeight = 160, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }; + var defaultStyle = new PdfHeadingStyle { + FontSize = 12, + LineHeight = 1, + SpacingBefore = 0, + SpacingAfter = 0 + }; + var spacedStyle = new PdfHeadingStyle { + FontSize = 12, + LineHeight = 1, + SpacingBefore = 24, + SpacingAfter = 0, + ApplySpacingBeforeAtTop = true + }; + + byte[] defaultBytes = PdfDoc.Create(options) + .Paragraph(p => p.Text("BeforeMarker"), style: new PdfParagraphStyle { SpacingAfter = 0 }) + .Spacer(80) + .H2("PagedHeadingMarker", style: defaultStyle) + .ToBytes(); + byte[] spacedBytes = PdfDoc.Create(options) + .Paragraph(p => p.Text("BeforeMarker"), style: new PdfParagraphStyle { SpacingAfter = 0 }) + .Spacer(80) + .H2("PagedHeadingMarker", style: spacedStyle) + .ToBytes(); + + using var defaultPdf = PdfDocument.Open(new MemoryStream(defaultBytes)); + using var spacedPdf = PdfDocument.Open(new MemoryStream(spacedBytes)); + + Assert.Equal(2, defaultPdf.NumberOfPages); + Assert.Equal(2, spacedPdf.NumberOfPages); + + double defaultTopY = FindWordStartY(defaultPdf.GetPage(2), "PagedHeadingMarker"); + double spacedTopY = FindWordStartY(spacedPdf.GetPage(2), "PagedHeadingMarker"); + + Assert.True(defaultTopY - spacedTopY >= 22, $"Expected opt-in top spacing after a page break to move heading text down. Default y: {defaultTopY:0.##}, spaced y: {spacedTopY:0.##}."); + } + [Fact] public void RowColumnHeading_SuppressesSpacingBeforeAtColumnTop() { var options = new PdfOptions { @@ -1557,6 +1863,12 @@ public void LinkAnnotations_RejectInvalidUriModelStateBeforeRendering() { Assert.Throws(() => PdfDoc.Create().H1("Plain heading", linkContents: "metadata without link")); + Assert.Throws(() => + PdfDoc.Create().H1("Conflicting heading link", linkUri: "https://evotec.xyz", linkDestinationName: "Intro")); + + Assert.Throws(() => + PdfDoc.Create().H1("Bookmark heading link", linkDestinationName: " ", linkContents: "metadata")); + byte[] png = CreateMinimalRgbPng(); Assert.Throws(() => PdfDoc.Create().Image(png, 24, 24, linkUri: "not-a-uri")); @@ -2278,6 +2590,13 @@ public void BookmarkLinks_RejectInvalidTargetsAndMissingBookmarks() { .ToBytes()); Assert.Contains("PDF bookmark link target 'MissingBookmark' was not found.", missingTargetException.Message, StringComparison.Ordinal); + + var missingHeadingTargetException = Assert.Throws(() => + PdfDoc.Create() + .H1("Jump to missing bookmark", linkDestinationName: "MissingHeadingBookmark") + .ToBytes()); + + Assert.Contains("PDF bookmark link target 'MissingHeadingBookmark' was not found.", missingHeadingTargetException.Message, StringComparison.Ordinal); } [Fact] @@ -5997,64 +6316,211 @@ public void RowColumnTableStyle_UsesConfiguredCellPaddingSides() { } [Fact] - public void TableStyle_CanDisableHeaderAndFooterBoldWithoutChangingDocumentFont() { - var style = TableStyles.Minimal(); - style.HeaderBold = false; - style.FooterBold = false; - style.FooterRowCount = 1; + public void TableStyle_UsesConfiguredPerCellPadding() { + var options = new PdfOptions { + PageWidth = 260, + PageHeight = 180, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }; + byte[] defaultBytes = CreateTablePerCellPaddingProbe(options, useRowColumnFlow: false, useCellPadding: false); + byte[] paddedBytes = CreateTablePerCellPaddingProbe(options, useRowColumnFlow: false, useCellPadding: true); - byte[] bytes = PdfDoc.Create(new PdfOptions { - DefaultFont = PdfStandardFont.Helvetica, - DefaultFontSize = 9 - }) - .Table(new[] { - new[] { "PlainHeader", "Status" }, - new[] { "BodyRow", "Readable" }, - new[] { "PlainFooter", "Ready" } - }, style: style) - .ToBytes(); + using var defaultPdf = PdfDocument.Open(new MemoryStream(defaultBytes)); + using var paddedPdf = PdfDocument.Open(new MemoryStream(paddedBytes)); + var defaultPage = defaultPdf.GetPage(1); + var paddedPage = paddedPdf.GetPage(1); - string content = Encoding.ASCII.GetString(bytes); + double defaultX = FindWordStartX(defaultPage, "CellPadMarker"); + double paddedX = FindWordStartX(paddedPage, "CellPadMarker"); + double defaultY = FindWordStartY(defaultPage, "CellPadMarker"); + double paddedY = FindWordStartY(paddedPage, "CellPadMarker"); - Assert.Contains("/F1 9 Tf", content); - Assert.DoesNotContain("/F2 9 Tf", content); + Assert.True(paddedX > defaultX + 16, $"Expected per-cell left padding to move text right. Default x: {defaultX:0.##}, padded x: {paddedX:0.##}."); + Assert.True(defaultY > paddedY + 10, $"Expected per-cell top padding to move text down. Default y: {defaultY:0.##}, padded y: {paddedY:0.##}."); } [Fact] - public void VectorRoundedRectangle_RendersBezierCornersFromSharedShapeDescriptor() { - byte[] bytes = PdfDoc.Create(new PdfOptions { - PageWidth = 240, - PageHeight = 180, - MarginLeft = 20, - MarginRight = 20, - MarginTop = 20, - MarginBottom = 20 - }) - .RoundedRectangle( - width: 100, - height: 36, - cornerRadius: 8, - strokeColor: PdfColor.FromRgb(26, 51, 77), - strokeWidth: 2, - fillColor: PdfColor.FromRgb(204, 179, 153), - align: PdfAlign.Center, - spacingBefore: 4, - spacingAfter: 6) - .ToBytes(); + public void RowColumnTableStyle_UsesConfiguredPerCellPadding() { + var options = new PdfOptions { + PageWidth = 260, + PageHeight = 180, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }; + byte[] defaultBytes = CreateTablePerCellPaddingProbe(options, useRowColumnFlow: true, useCellPadding: false); + byte[] paddedBytes = CreateTablePerCellPaddingProbe(options, useRowColumnFlow: true, useCellPadding: true); - string content = Encoding.ASCII.GetString(bytes); + using var defaultPdf = PdfDocument.Open(new MemoryStream(defaultBytes)); + using var paddedPdf = PdfDocument.Open(new MemoryStream(paddedBytes)); + var defaultPage = defaultPdf.GetPage(1); + var paddedPage = paddedPdf.GetPage(1); - Assert.Contains("0.8 0.702 0.6 rg", content); - Assert.Contains("0.102 0.2 0.302 RG", content); - Assert.Contains("2 w", content); - Assert.Contains("78 124 m", content); - Assert.Contains("162 124 l", content); - Assert.Contains("166.418 124 170 127.582 170 132 c", content); - Assert.Contains("70 127.582 73.582 124 78 124 c h B", content); + double defaultX = FindWordStartX(defaultPage, "CellPadMarker"); + double paddedX = FindWordStartX(paddedPage, "CellPadMarker"); + double defaultY = FindWordStartY(defaultPage, "CellPadMarker"); + double paddedY = FindWordStartY(paddedPage, "CellPadMarker"); + + Assert.True(paddedX > defaultX + 16, $"Expected row-column per-cell left padding to move text right. Default x: {defaultX:0.##}, padded x: {paddedX:0.##}."); + Assert.True(defaultY > paddedY + 10, $"Expected row-column per-cell top padding to move text down. Default y: {defaultY:0.##}, padded y: {paddedY:0.##}."); } [Fact] - public void VectorLine_RendersStrokeOperatorFromSharedShapeDescriptor() { + public void TableStyle_UsesConfiguredPerCellAlignment() { + var options = new PdfOptions { + PageWidth = 280, + PageHeight = 200, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }; + byte[] defaultBytes = CreateTablePerCellAlignmentProbe(options, useRowColumnFlow: false, useCellAlignment: false); + byte[] alignedBytes = CreateTablePerCellAlignmentProbe(options, useRowColumnFlow: false, useCellAlignment: true); + + using var defaultPdf = PdfDocument.Open(new MemoryStream(defaultBytes)); + using var alignedPdf = PdfDocument.Open(new MemoryStream(alignedBytes)); + var defaultPage = defaultPdf.GetPage(1); + var alignedPage = alignedPdf.GetPage(1); + + double defaultX = FindWordStartX(defaultPage, "CellAlignMarker"); + double alignedX = FindWordStartX(alignedPage, "CellAlignMarker"); + double defaultY = FindWordStartY(defaultPage, "CellAlignMarker"); + double alignedY = FindWordStartY(alignedPage, "CellAlignMarker"); + + Assert.True(alignedX > defaultX + 30, $"Expected per-cell right alignment to move text right. Default x: {defaultX:0.##}, aligned x: {alignedX:0.##}."); + Assert.True(defaultY > alignedY + 30, $"Expected per-cell bottom alignment to move text down. Default y: {defaultY:0.##}, aligned y: {alignedY:0.##}."); + } + + [Fact] + public void RowColumnTableStyle_UsesConfiguredPerCellAlignment() { + var options = new PdfOptions { + PageWidth = 280, + PageHeight = 200, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }; + byte[] defaultBytes = CreateTablePerCellAlignmentProbe(options, useRowColumnFlow: true, useCellAlignment: false); + byte[] alignedBytes = CreateTablePerCellAlignmentProbe(options, useRowColumnFlow: true, useCellAlignment: true); + + using var defaultPdf = PdfDocument.Open(new MemoryStream(defaultBytes)); + using var alignedPdf = PdfDocument.Open(new MemoryStream(alignedBytes)); + var defaultPage = defaultPdf.GetPage(1); + var alignedPage = alignedPdf.GetPage(1); + + double defaultX = FindWordStartX(defaultPage, "CellAlignMarker"); + double alignedX = FindWordStartX(alignedPage, "CellAlignMarker"); + double defaultY = FindWordStartY(defaultPage, "CellAlignMarker"); + double alignedY = FindWordStartY(alignedPage, "CellAlignMarker"); + + Assert.True(alignedX > defaultX + 30, $"Expected row-column per-cell right alignment to move text right. Default x: {defaultX:0.##}, aligned x: {alignedX:0.##}."); + Assert.True(defaultY > alignedY + 30, $"Expected row-column per-cell bottom alignment to move text down. Default y: {defaultY:0.##}, aligned y: {alignedY:0.##}."); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TableStyle_UsesConfiguredCellSpacing(bool useRowColumnFlow) { + var options = new PdfOptions { + PageWidth = 320, + PageHeight = 240, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 10 + }; + byte[] defaultBytes = CreateTableCellSpacingProbe(options, 0, useRowColumnFlow); + byte[] spacedBytes = CreateTableCellSpacingProbe(options, 12, useRowColumnFlow); + + using var defaultPdf = PdfDocument.Open(new MemoryStream(defaultBytes)); + using var spacedPdf = PdfDocument.Open(new MemoryStream(spacedBytes)); + var defaultPage = defaultPdf.GetPage(1); + var spacedPage = spacedPdf.GetPage(1); + + double defaultHorizontalGap = FindWordStartX(defaultPage, "SpacingB1") - FindWordStartX(defaultPage, "SpacingA1"); + double spacedHorizontalGap = FindWordStartX(spacedPage, "SpacingB1") - FindWordStartX(spacedPage, "SpacingA1"); + double defaultVerticalGap = FindWordStartY(defaultPage, "SpacingA1") - FindWordStartY(defaultPage, "SpacingA2"); + double spacedVerticalGap = FindWordStartY(spacedPage, "SpacingA1") - FindWordStartY(spacedPage, "SpacingA2"); + + Assert.True(spacedHorizontalGap > defaultHorizontalGap + 10, $"Expected cell spacing to increase horizontal cell distance. Default: {defaultHorizontalGap:0.##}, spaced: {spacedHorizontalGap:0.##}."); + Assert.True(spacedVerticalGap > defaultVerticalGap + 10, $"Expected cell spacing to increase vertical row distance. Default: {defaultVerticalGap:0.##}, spaced: {spacedVerticalGap:0.##}."); + } + + [Fact] + public void TableStyle_CanDisableHeaderAndFooterBoldWithoutChangingDocumentFont() { + var style = TableStyles.Minimal(); + style.HeaderBold = false; + style.FooterBold = false; + style.FooterRowCount = 1; + + byte[] bytes = PdfDoc.Create(new PdfOptions { + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 9 + }) + .Table(new[] { + new[] { "PlainHeader", "Status" }, + new[] { "BodyRow", "Readable" }, + new[] { "PlainFooter", "Ready" } + }, style: style) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + + Assert.Contains("/F1 9 Tf", content); + Assert.DoesNotContain("/F2 9 Tf", content); + } + + [Fact] + public void VectorRoundedRectangle_RendersBezierCornersFromSharedShapeDescriptor() { + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 240, + PageHeight = 180, + MarginLeft = 20, + MarginRight = 20, + MarginTop = 20, + MarginBottom = 20 + }) + .RoundedRectangle( + width: 100, + height: 36, + cornerRadius: 8, + strokeColor: PdfColor.FromRgb(26, 51, 77), + strokeWidth: 2, + fillColor: PdfColor.FromRgb(204, 179, 153), + align: PdfAlign.Center, + spacingBefore: 4, + spacingAfter: 6) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + + Assert.Contains("0.8 0.702 0.6 rg", content); + Assert.Contains("0.102 0.2 0.302 RG", content); + Assert.Contains("2 w", content); + Assert.Contains("78 124 m", content); + Assert.Contains("162 124 l", content); + Assert.Contains("166.418 124 170 127.582 170 132 c", content); + Assert.Contains("70 127.582 73.582 124 78 124 c h B", content); + } + + [Fact] + public void VectorLine_RendersStrokeOperatorFromSharedShapeDescriptor() { byte[] bytes = PdfDoc.Create(new PdfOptions { PageWidth = 240, PageHeight = 180, @@ -7060,8 +7526,8 @@ public void Table_CellTextThatEscapesCellRectanglesIsClipped() { .ToBytes(); string topLevelContent = string.Join("\n", GetPageContentStreams(topLevelBytes, 1)); - int topLevelClipCount = Regex.Matches(topLevelContent, " re W n\\nBT\\n/F").Count; - Assert.True(topLevelClipCount >= 5, "Expected top-level table cell text to be clipped by PDF cell rectangles."); + int topLevelClipCount = Regex.Matches(topLevelContent, " re W n\\nBT\\n[\\s\\S]{0,160}?/F").Count; + Assert.True(topLevelClipCount >= 4, "Expected top-level table cell text to be clipped by PDF cell rectangles."); byte[] rowColumnBytes = PdfDoc.Create(new PdfOptions { PageWidth = 320, @@ -7085,8 +7551,8 @@ public void Table_CellTextThatEscapesCellRectanglesIsClipped() { .ToBytes(); string rowColumnContent = string.Join("\n", GetPageContentStreams(rowColumnBytes, 1)); - int rowColumnClipCount = Regex.Matches(rowColumnContent, " re W n\\nBT\\n/F").Count; - Assert.True(rowColumnClipCount >= 5, "Expected row-column table cell text to be clipped by PDF cell rectangles."); + int rowColumnClipCount = Regex.Matches(rowColumnContent, " re W n\\nBT\\n[\\s\\S]{0,160}?/F").Count; + Assert.True(rowColumnClipCount >= 4, "Expected row-column table cell text to be clipped by PDF cell rectangles."); } [Fact] @@ -7174,6 +7640,44 @@ public void Table_RepeatsConfiguredHeaderRowsAcrossPages() { Assert.Contains("Check 30", pdf.GetPage(pdf.NumberOfPages).Text); } + [Fact] + public void Table_CanStyleHeaderRowsWithoutRepeatingThemAcrossPages() { + var options = new PdfOptions { + PageWidth = 360, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 9 + }; + var style = TableStyles.Minimal(); + style.HeaderRowCount = 1; + style.RepeatHeaderRowCount = 0; + style.ColumnWidthWeights = new List { 1, 1 }; + + var rows = new List { + new[] { "VisualHdr", "State" } + }; + for (int i = 1; i <= 30; i++) { + rows.Add(new[] { "StyledOnly " + i.ToString(CultureInfo.InvariantCulture), "Ready" }); + } + + byte[] bytes = PdfDoc.Create(options) + .Table(rows, style: style) + .ToBytes(); + + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.True(pdf.NumberOfPages > 1, "Expected the visually styled header table to continue onto another page."); + + int headerOccurrences = pdf.GetPages() + .SelectMany(page => page.GetWords()) + .Count(word => word.Text == "VisualHdr"); + Assert.Equal(1, headerOccurrences); + Assert.Contains("StyledOnly 30", pdf.GetPage(pdf.NumberOfPages).Text); + } + [Fact] public void RowColumnTable_RepeatsHeaderRowsAcrossPages() { var options = new PdfOptions { @@ -7623,13 +8127,14 @@ public void RowColumnTable_RendersConfiguredCellFills() { } [Fact] - public void Table_RendersConfiguredCellBorders() { + public void Table_RendersConfiguredCellDataBarsBehindText() { var style = TableStyles.Minimal(); - style.BorderColor = null; - style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { - [(2, 1)] = new PdfCellBorder { + style.HeaderFill = null; + style.RowStripeFill = null; + style.CellDataBars = new Dictionary<(int Row, int Column), PdfCellDataBar> { + [(1, 1)] = new PdfCellDataBar { Color = new PdfColor(0.12, 0.34, 0.56), - Width = 1.7 + Ratio = 0.5 } }; @@ -7643,27 +8148,29 @@ public void Table_RendersConfiguredCellBorders() { }) .Table(new[] { new[] { "Metric", "Status" }, - new[] { "Queue", "Healthy" }, - new[] { "Latency", "Warning" } + new[] { "Progress", "50" }, + new[] { "Done", "100" } }, style: style) .ToBytes(); string content = Encoding.ASCII.GetString(bytes); - int borderColorCount = content.Split(new[] { "0.12 0.34 0.56 RG" }, StringSplitOptions.None).Length - 1; - Assert.Equal(1, borderColorCount); - Assert.Contains("1.7 w", content); - Assert.Contains(" re S", content); + Assert.Contains("0.12 0.34 0.56 rg", content, StringComparison.Ordinal); + using (PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes))) { + Assert.Contains("50", pdf.GetPage(1).Text, StringComparison.Ordinal); + } + Assert.Contains(" re f", content, StringComparison.Ordinal); } [Fact] - public void RowColumnTable_RendersConfiguredCellBorders() { + public void RowColumnTable_RendersConfiguredCellDataBarsBehindText() { var style = TableStyles.Minimal(); - style.BorderColor = null; - style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { - [(2, 1)] = new PdfCellBorder { + style.HeaderFill = null; + style.RowStripeFill = null; + style.CellDataBars = new Dictionary<(int Row, int Column), PdfCellDataBar> { + [(1, 1)] = new PdfCellDataBar { Color = new PdfColor(0.12, 0.34, 0.56), - Width = 1.7 + Ratio = 0.5 } }; @@ -7682,30 +8189,35 @@ public void RowColumnTable_RendersConfiguredCellBorders() { row.Column(100, column => column.Table(new[] { new[] { "Metric", "Status" }, - new[] { "Queue", "Healthy" }, - new[] { "Latency", "Warning" } + new[] { "Progress", "50" }, + new[] { "Done", "100" } }, style: style)))))) .ToBytes(); - string content = Encoding.ASCII.GetString(bytes); - int borderColorCount = content.Split(new[] { "0.12 0.34 0.56 RG" }, StringSplitOptions.None).Length - 1; + string contentStream = Encoding.ASCII.GetString(bytes); - Assert.Equal(1, borderColorCount); - Assert.Contains("1.7 w", content); - Assert.Contains(" re S", content); + Assert.Contains("0.12 0.34 0.56 rg", contentStream, StringComparison.Ordinal); + using (PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes))) { + Assert.Contains("50", pdf.GetPage(1).Text, StringComparison.Ordinal); + } + Assert.Contains(" re f", contentStream, StringComparison.Ordinal); } [Fact] - public void Table_RendersConfiguredCellBorderSides() { + public void Table_RendersConfiguredCellIconsBeforeText() { var style = TableStyles.Minimal(); - style.BorderColor = null; - style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { - [(2, 1)] = new PdfCellBorder { - Color = new PdfColor(0.2, 0.3, 0.4), - Width = 2.2, - Right = false, - Bottom = false, - Left = false + style.HeaderFill = null; + style.RowStripeFill = null; + style.CellIcons = new Dictionary<(int Row, int Column), PdfCellIcon> { + [(1, 1)] = new PdfCellIcon { + Kind = PdfCellIconKind.Circle, + Color = new PdfColor(0.12, 0.34, 0.56), + Size = 8 + } + }; + style.CellPaddings = new Dictionary<(int Row, int Column), PdfCellPadding> { + [(1, 1)] = new PdfCellPadding { + Left = 16 } }; @@ -7719,28 +8231,37 @@ public void Table_RendersConfiguredCellBorderSides() { }) .Table(new[] { new[] { "Metric", "Status" }, - new[] { "Queue", "Healthy" }, - new[] { "Total", "Warning" } + new[] { "Progress", "50" }, + new[] { "Done", "100" } }, style: style) .ToBytes(); string content = Encoding.ASCII.GetString(bytes); - int borderColorCount = content.Split(new[] { "0.2 0.3 0.4 RG" }, StringSplitOptions.None).Length - 1; - Assert.Equal(1, borderColorCount); - Assert.Contains("2.2 w", content); - Assert.Contains(" l S", content); - Assert.DoesNotContain(" re S", content); + Assert.Contains("0.12 0.34 0.56 rg", content, StringComparison.Ordinal); + Assert.Contains(" c ", content, StringComparison.Ordinal); + using (PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes))) { + Assert.Contains("50", pdf.GetPage(1).Text, StringComparison.Ordinal); + } } [Fact] - public void Table_RendersConfiguredRowSeparatorsWithoutCellBorderDictionary() { + public void RowColumnTable_RendersConfiguredCellIconsBeforeText() { var style = TableStyles.Minimal(); - style.BorderColor = null; - style.RowSeparatorColor = new PdfColor(0.12, 0.34, 0.56); - style.RowSeparatorWidth = 0.6; - style.HeaderSeparatorColor = new PdfColor(0.7, 0.2, 0.1); - style.HeaderSeparatorWidth = 1.1; + style.HeaderFill = null; + style.RowStripeFill = null; + style.CellIcons = new Dictionary<(int Row, int Column), PdfCellIcon> { + [(1, 1)] = new PdfCellIcon { + Kind = PdfCellIconKind.TriangleUp, + Color = new PdfColor(0.12, 0.34, 0.56), + Size = 8 + } + }; + style.CellPaddings = new Dictionary<(int Row, int Column), PdfCellPadding> { + [(1, 1)] = new PdfCellPadding { + Left = 16 + } + }; byte[] bytes = PdfDoc.Create(new PdfOptions { PageWidth = 320, @@ -7750,33 +8271,37 @@ public void Table_RendersConfiguredRowSeparatorsWithoutCellBorderDictionary() { MarginTop = 30, MarginBottom = 30 }) - .Table(new[] { - new[] { "Metric", "Status" }, - new[] { "Queue", "Healthy" }, - new[] { "Latency", "Warning" } - }, style: style) + .Compose(document => + document.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => + column.Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Progress", "50" }, + new[] { "Done", "100" } + }, style: style)))))) .ToBytes(); - string content = Encoding.ASCII.GetString(bytes); - int bodySeparatorCount = content.Split(new[] { "0.12 0.34 0.56 RG" }, StringSplitOptions.None).Length - 1; - int headerSeparatorCount = content.Split(new[] { "0.7 0.2 0.1 RG" }, StringSplitOptions.None).Length - 1; + string contentStream = Encoding.ASCII.GetString(bytes); - Assert.Equal(2, bodySeparatorCount); - Assert.Equal(1, headerSeparatorCount); - Assert.Contains("0.6 w", content); - Assert.Contains("1.1 w", content); - Assert.Contains(" l S", content); - Assert.DoesNotContain(" re S", content); + Assert.Contains("0.12 0.34 0.56 rg", contentStream, StringComparison.Ordinal); + Assert.Contains(" l ", contentStream, StringComparison.Ordinal); + using (PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes))) { + Assert.Contains("50", pdf.GetPage(1).Text, StringComparison.Ordinal); + } } [Fact] - public void RowColumnTable_RendersConfiguredRowSeparatorsWithoutCellBorderDictionary() { + public void Table_RendersConfiguredCellBorders() { var style = TableStyles.Minimal(); style.BorderColor = null; - style.RowSeparatorColor = new PdfColor(0.12, 0.34, 0.56); - style.RowSeparatorWidth = 0.6; - style.HeaderSeparatorColor = new PdfColor(0.7, 0.2, 0.1); - style.HeaderSeparatorWidth = 1.1; + style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { + [(2, 1)] = new PdfCellBorder { + Color = new PdfColor(0.12, 0.34, 0.56), + Width = 1.7 + } + }; byte[] bytes = PdfDoc.Create(new PdfOptions { PageWidth = 320, @@ -7786,16 +8311,306 @@ public void RowColumnTable_RendersConfiguredRowSeparatorsWithoutCellBorderDictio MarginTop = 30, MarginBottom = 30 }) - .Compose(document => - document.Page(page => - page.Content(content => - content.Row(row => - row.Column(100, column => - column.Table(new[] { - new[] { "Metric", "Status" }, - new[] { "Queue", "Healthy" }, - new[] { "Latency", "Warning" } - }, style: style)))))) + .Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Queue", "Healthy" }, + new[] { "Latency", "Warning" } + }, style: style) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int borderColorCount = content.Split(new[] { "0.12 0.34 0.56 RG" }, StringSplitOptions.None).Length - 1; + + Assert.Equal(1, borderColorCount); + Assert.Contains("1.7 w", content); + Assert.Contains(" re S", content); + } + + [Fact] + public void Table_RendersConfiguredSideSpecificCellBorders() { + var style = TableStyles.Minimal(); + style.BorderColor = null; + style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { + [(1, 0)] = new PdfCellBorder { + Color = null, + Width = 0, + Top = true, + Right = false, + Bottom = false, + Left = true, + TopBorder = new PdfCellBorderSide { + Color = PdfColor.FromRgb(255, 0, 0), + Width = 2 + }, + LeftBorder = new PdfCellBorderSide { + Color = PdfColor.FromRgb(0, 0, 255), + Width = 1.5 + } + } + }; + + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 320, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30 + }) + .Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Queue", "Healthy" } + }, style: style) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + + Assert.Contains("1 0 0 RG", content); + Assert.Contains("2 w", content); + Assert.Contains("0 0 1 RG", content); + Assert.Contains("1.5 w", content); + } + + [Fact] + public void Table_RendersConfiguredDashedCellBorders() { + var style = TableStyles.Minimal(); + style.BorderColor = null; + style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { + [(1, 0)] = new PdfCellBorder { + Color = PdfColor.FromRgb(18, 52, 86), + Width = 1, + DashStyle = OfficeStrokeDashStyle.Dash + }, + [(1, 1)] = new PdfCellBorder { + Color = null, + TopBorder = new PdfCellBorderSide { + Color = PdfColor.FromRgb(120, 80, 40), + Width = 1.5, + DashStyle = OfficeStrokeDashStyle.Dot + }, + BottomBorder = new PdfCellBorderSide { + Color = PdfColor.FromRgb(40, 120, 80), + Width = 1.25, + DashStyle = OfficeStrokeDashStyle.DashDot + } + } + }; + + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 320, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30 + }) + .Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Queue", "Healthy" } + }, style: style) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + + Assert.Contains("[3 1.5] 0 d", content, StringComparison.Ordinal); + Assert.Contains("[1.5 2.25] 0 d", content, StringComparison.Ordinal); + Assert.Contains("[3.75 1.875 1.25 1.875] 0 d", content, StringComparison.Ordinal); + Assert.Contains("1 J", content, StringComparison.Ordinal); + Assert.True(content.Contains(" m ", StringComparison.Ordinal) && content.Contains(" l S", StringComparison.Ordinal), "Expected diagonal cell borders to emit line segments instead of only rectangle borders."); + } + + [Fact] + public void Table_RendersConfiguredDoubleAndDiagonalCellBorders() { + var style = TableStyles.Minimal(); + style.BorderColor = null; + style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { + [(1, 0)] = new PdfCellBorder { + Color = PdfColor.FromRgb(68, 85, 102), + Width = 1, + LineStyle = PdfCellBorderLineStyle.TwoLine, + DiagonalUp = true, + DiagonalDown = true + }, + [(1, 1)] = new PdfCellBorder { + Color = null, + TopBorder = new PdfCellBorderSide { + Color = PdfColor.FromRgb(120, 80, 40), + Width = 1.25, + LineStyle = PdfCellBorderLineStyle.TwoLine + }, + DiagonalDown = true, + DiagonalDownBorder = new PdfCellBorderSide { + Color = PdfColor.FromRgb(40, 120, 80), + Width = 0.75, + DashStyle = OfficeStrokeDashStyle.Dash, + LineStyle = PdfCellBorderLineStyle.TwoLine + } + } + }; + + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 320, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30 + }) + .Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Queue", "Healthy" } + }, style: style) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + + Assert.Contains("0.267 0.333 0.4 RG", content, StringComparison.Ordinal); + Assert.Contains("0.157 0.471 0.314 RG", content, StringComparison.Ordinal); + Assert.Contains("[2.25 1.125] 0 d", content, StringComparison.Ordinal); + Assert.True(content.Split(new[] { " S" }, StringSplitOptions.None).Length - 1 >= 10, "Expected double and diagonal cell borders to emit multiple stroked lines."); + Assert.True(content.Contains(" m ", StringComparison.Ordinal) && content.Contains(" l S", StringComparison.Ordinal), "Expected diagonal cell borders to emit line segments instead of only rectangle borders."); + } + + [Fact] + public void RowColumnTable_RendersConfiguredCellBorders() { + var style = TableStyles.Minimal(); + style.BorderColor = null; + style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { + [(2, 1)] = new PdfCellBorder { + Color = new PdfColor(0.12, 0.34, 0.56), + Width = 1.7 + } + }; + + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 320, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30 + }) + .Compose(document => + document.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => + column.Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Queue", "Healthy" }, + new[] { "Latency", "Warning" } + }, style: style)))))) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int borderColorCount = content.Split(new[] { "0.12 0.34 0.56 RG" }, StringSplitOptions.None).Length - 1; + + Assert.Equal(1, borderColorCount); + Assert.Contains("1.7 w", content); + Assert.Contains(" re S", content); + } + + [Fact] + public void Table_RendersConfiguredCellBorderSides() { + var style = TableStyles.Minimal(); + style.BorderColor = null; + style.CellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { + [(2, 1)] = new PdfCellBorder { + Color = new PdfColor(0.2, 0.3, 0.4), + Width = 2.2, + Right = false, + Bottom = false, + Left = false + } + }; + + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 320, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30 + }) + .Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Queue", "Healthy" }, + new[] { "Total", "Warning" } + }, style: style) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int borderColorCount = content.Split(new[] { "0.2 0.3 0.4 RG" }, StringSplitOptions.None).Length - 1; + + Assert.Equal(1, borderColorCount); + Assert.Contains("2.2 w", content); + Assert.Contains(" l S", content); + Assert.DoesNotContain(" re S", content); + } + + [Fact] + public void Table_RendersConfiguredRowSeparatorsWithoutCellBorderDictionary() { + var style = TableStyles.Minimal(); + style.BorderColor = null; + style.RowSeparatorColor = new PdfColor(0.12, 0.34, 0.56); + style.RowSeparatorWidth = 0.6; + style.HeaderSeparatorColor = new PdfColor(0.7, 0.2, 0.1); + style.HeaderSeparatorWidth = 1.1; + + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 320, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30 + }) + .Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Queue", "Healthy" }, + new[] { "Latency", "Warning" } + }, style: style) + .ToBytes(); + + string content = Encoding.ASCII.GetString(bytes); + int bodySeparatorCount = content.Split(new[] { "0.12 0.34 0.56 RG" }, StringSplitOptions.None).Length - 1; + int headerSeparatorCount = content.Split(new[] { "0.7 0.2 0.1 RG" }, StringSplitOptions.None).Length - 1; + + Assert.Equal(2, bodySeparatorCount); + Assert.Equal(1, headerSeparatorCount); + Assert.Contains("0.6 w", content); + Assert.Contains("1.1 w", content); + Assert.Contains(" l S", content); + Assert.DoesNotContain(" re S", content); + } + + [Fact] + public void RowColumnTable_RendersConfiguredRowSeparatorsWithoutCellBorderDictionary() { + var style = TableStyles.Minimal(); + style.BorderColor = null; + style.RowSeparatorColor = new PdfColor(0.12, 0.34, 0.56); + style.RowSeparatorWidth = 0.6; + style.HeaderSeparatorColor = new PdfColor(0.7, 0.2, 0.1); + style.HeaderSeparatorWidth = 1.1; + + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 320, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30 + }) + .Compose(document => + document.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => + column.Table(new[] { + new[] { "Metric", "Status" }, + new[] { "Queue", "Healthy" }, + new[] { "Latency", "Warning" } + }, style: style)))))) .ToBytes(); string content = Encoding.ASCII.GetString(bytes); @@ -8168,6 +8983,83 @@ public void Table_UsesConfiguredMinimumRowHeight() { Assert.True(betaY - gammaY >= 34, $"Expected minimum row height spacing between second and third row. Beta y: {betaY:0.##}, Gamma y: {gammaY:0.##}."); } + [Fact] + public void Table_UsesConfiguredPerRowMinimumHeights() { + var options = new PdfOptions { + PageWidth = 320, + PageHeight = 260, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 9 + }; + var style = TableStyles.Minimal(); + style.HeaderRowCount = 0; + style.MinRowHeight = 18; + style.RowMinHeights = new List { 18, 54, 18 }; + + byte[] bytes = PdfDoc.Create(options) + .Table(new[] { + new[] { "Alpha", "Ready" }, + new[] { "Beta", "Ready" }, + new[] { "Gamma", "Ready" } + }, style: style) + .ToBytes(); + + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + var page = pdf.GetPage(1); + + double alphaY = FindWordStartY(page, "Alpha"); + double betaY = FindWordStartY(page, "Beta"); + double gammaY = FindWordStartY(page, "Gamma"); + + Assert.InRange(alphaY - betaY, 16D, 28D); + Assert.True(betaY - gammaY >= 52D, $"Expected second row-specific minimum height to push the third row down. Beta y: {betaY:0.##}, Gamma y: {gammaY:0.##}."); + } + + [Fact] + public void RowColumnTable_UsesConfiguredPerRowMinimumHeights() { + var options = new PdfOptions { + PageWidth = 320, + PageHeight = 260, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 9 + }; + var style = TableStyles.Minimal(); + style.HeaderRowCount = 0; + style.MinRowHeight = 18; + style.RowMinHeights = new List { 18, 54, 18 }; + var rows = new[] { + new[] { "Alpha", "Ready" }, + new[] { "Beta", "Ready" }, + new[] { "Gamma", "Ready" } + }; + + byte[] bytes = PdfDoc.Create(options) + .Compose(compose => + compose.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => column.Table(rows, style: style)))))) + .ToBytes(); + + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + var page = pdf.GetPage(1); + + double alphaY = FindWordStartY(page, "Alpha"); + double betaY = FindWordStartY(page, "Beta"); + double gammaY = FindWordStartY(page, "Gamma"); + + Assert.InRange(alphaY - betaY, 16D, 28D); + Assert.True(betaY - gammaY >= 52D, $"Expected row-column table row-specific minimum height to push the third row down. Beta y: {betaY:0.##}, Gamma y: {gammaY:0.##}."); + } + [Fact] public void Table_UsesConfiguredSpacingBeforeAndAfter() { var options = new PdfOptions { @@ -10502,25 +11394,161 @@ public void Table_SplitsSingleTallRowsAcrossPages() { .ToBytes(); using var pdf = PdfDocument.Open(new MemoryStream(bytes)); - Assert.True(pdf.NumberOfPages > 1, "Expected one very tall table row to continue onto another page."); + Assert.True(pdf.NumberOfPages > 1, "Expected one very tall table row to continue onto another page."); + + for (int pageNumber = 1; pageNumber <= pdf.NumberOfPages; pageNumber++) { + var page = pdf.GetPage(pageNumber); + Assert.Contains("Type", page.Text); + Assert.Contains("Description", page.Text); + + double bottomMost = page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .Min(letter => letter.StartBaseLine.Y); + Assert.True(bottomMost >= options.MarginBottom - 2, $"Expected split row text to stay above the bottom margin on page {pageNumber}."); + } + + Assert.Contains("segment01", pdf.GetPage(1).Text); + Assert.Contains("segment60", pdf.GetPage(pdf.NumberOfPages).Text); + } + + [Fact] + public void RowColumnTable_SplitsSingleTallRowsAcrossPages() { + var options = new PdfOptions { + PageWidth = 360, + PageHeight = 180, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 9 + }; + var style = TableStyles.Minimal(); + style.ColumnWidthPoints = new List { 70, null }; + + string longValue = string.Join(" ", Enumerable.Range(1, 60).Select(i => "segment" + i.ToString("00"))); + + byte[] bytes = PdfDoc.Create(options) + .Compose(document => + document.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => + column.Table(new[] { + new[] { "Type", "Description" }, + new[] { "Finding", longValue } + }, style: style)))))) + .ToBytes(); + + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.True(pdf.NumberOfPages > 1, "Expected one very tall row-column table row to continue onto another page."); + + for (int pageNumber = 1; pageNumber <= pdf.NumberOfPages; pageNumber++) { + var page = pdf.GetPage(pageNumber); + Assert.Contains("Type", page.Text); + Assert.Contains("Description", page.Text); + + double bottomMost = page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .Min(letter => letter.StartBaseLine.Y); + Assert.True(bottomMost >= options.MarginBottom - 2, $"Expected split row-column row text to stay above the bottom margin on page {pageNumber}."); + } + + Assert.Contains("segment01", pdf.GetPage(1).Text); + Assert.Contains("segment60", pdf.GetPage(pdf.NumberOfPages).Text); + } + + [Fact] + public void Table_DisallowRowBreakRejectsSingleTallRows() { + var style = TableStyles.Minimal(); + style.AllowRowBreakAcrossPages = false; + style.ColumnWidthPoints = new List { 70, null }; + + string longValue = string.Join(" ", Enumerable.Range(1, 60).Select(i => "segment" + i.ToString("00"))); + + var exception = Assert.Throws(() => + PdfDoc.Create(new PdfOptions { + PageWidth = 360, + PageHeight = 180, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 9 + }) + .Table(new[] { + new[] { "Type", "Description" }, + new[] { "Finding", longValue } + }, style: style) + .ToBytes()); + + Assert.Contains("Table row height exceeds the available page content height and row splitting is disabled.", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Table_RowBreakPolicyAllowsSingleTallRows() { + var options = new PdfOptions { + PageWidth = 360, + PageHeight = 180, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 9 + }; + var style = TableStyles.Minimal(); + style.AllowRowBreakAcrossPages = false; + style.RowAllowBreakAcrossPages = new List { null, true }; + style.ColumnWidthPoints = new List { 70, null }; + + string longValue = string.Join(" ", Enumerable.Range(1, 60).Select(i => "segment" + i.ToString("00"))); + + byte[] bytes = PdfDoc.Create(options) + .Table(new[] { + new[] { "Type", "Description" }, + new[] { "Finding", longValue } + }, style: style) + .ToBytes(); + + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.True(pdf.NumberOfPages > 1, "Expected the per-row break policy to allow the tall row to split."); + Assert.Contains("segment01", pdf.GetPage(1).Text); + Assert.Contains("segment60", pdf.GetPage(pdf.NumberOfPages).Text); + } - for (int pageNumber = 1; pageNumber <= pdf.NumberOfPages; pageNumber++) { - var page = pdf.GetPage(pageNumber); - Assert.Contains("Type", page.Text); - Assert.Contains("Description", page.Text); + [Fact] + public void Table_RowBreakPolicyRejectsSingleTallRows() { + var style = TableStyles.Minimal(); + style.AllowRowBreakAcrossPages = true; + style.RowAllowBreakAcrossPages = new List { null, false }; + style.ColumnWidthPoints = new List { 70, null }; - double bottomMost = page.Letters - .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) - .Min(letter => letter.StartBaseLine.Y); - Assert.True(bottomMost >= options.MarginBottom - 2, $"Expected split row text to stay above the bottom margin on page {pageNumber}."); - } + string longValue = string.Join(" ", Enumerable.Range(1, 60).Select(i => "segment" + i.ToString("00"))); - Assert.Contains("segment01", pdf.GetPage(1).Text); - Assert.Contains("segment60", pdf.GetPage(pdf.NumberOfPages).Text); + var exception = Assert.Throws(() => + PdfDoc.Create(new PdfOptions { + PageWidth = 360, + PageHeight = 180, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 9 + }) + .Table(new[] { + new[] { "Type", "Description" }, + new[] { "Finding", longValue } + }, style: style) + .ToBytes()); + + Assert.Contains("Table row height exceeds the available page content height and row splitting is disabled.", exception.Message, StringComparison.Ordinal); } [Fact] - public void RowColumnTable_SplitsSingleTallRowsAcrossPages() { + public void RowColumnTable_RowBreakPolicyAllowsSingleTallRows() { var options = new PdfOptions { PageWidth = 360, PageHeight = 180, @@ -10532,6 +11560,8 @@ public void RowColumnTable_SplitsSingleTallRowsAcrossPages() { DefaultFontSize = 9 }; var style = TableStyles.Minimal(); + style.AllowRowBreakAcrossPages = false; + style.RowAllowBreakAcrossPages = new List { null, true }; style.ColumnWidthPoints = new List { 70, null }; string longValue = string.Join(" ", Enumerable.Range(1, 60).Select(i => "segment" + i.ToString("00"))); @@ -10549,25 +11579,13 @@ public void RowColumnTable_SplitsSingleTallRowsAcrossPages() { .ToBytes(); using var pdf = PdfDocument.Open(new MemoryStream(bytes)); - Assert.True(pdf.NumberOfPages > 1, "Expected one very tall row-column table row to continue onto another page."); - - for (int pageNumber = 1; pageNumber <= pdf.NumberOfPages; pageNumber++) { - var page = pdf.GetPage(pageNumber); - Assert.Contains("Type", page.Text); - Assert.Contains("Description", page.Text); - - double bottomMost = page.Letters - .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) - .Min(letter => letter.StartBaseLine.Y); - Assert.True(bottomMost >= options.MarginBottom - 2, $"Expected split row-column row text to stay above the bottom margin on page {pageNumber}."); - } - + Assert.True(pdf.NumberOfPages > 1, "Expected the per-row break policy to allow the row-column table row to split."); Assert.Contains("segment01", pdf.GetPage(1).Text); Assert.Contains("segment60", pdf.GetPage(pdf.NumberOfPages).Text); } [Fact] - public void Table_DisallowRowBreakRejectsSingleTallRows() { + public void RowColumnTable_DisallowRowBreakRejectsSingleTallRows() { var style = TableStyles.Minimal(); style.AllowRowBreakAcrossPages = false; style.ColumnWidthPoints = new List { 70, null }; @@ -10585,19 +11603,25 @@ public void Table_DisallowRowBreakRejectsSingleTallRows() { DefaultFont = PdfStandardFont.Helvetica, DefaultFontSize = 9 }) - .Table(new[] { - new[] { "Type", "Description" }, - new[] { "Finding", longValue } - }, style: style) + .Compose(document => + document.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => + column.Table(new[] { + new[] { "Type", "Description" }, + new[] { "Finding", longValue } + }, style: style)))))) .ToBytes()); Assert.Contains("Table row height exceeds the available page content height and row splitting is disabled.", exception.Message, StringComparison.Ordinal); } [Fact] - public void RowColumnTable_DisallowRowBreakRejectsSingleTallRows() { + public void RowColumnTable_RowBreakPolicyRejectsSingleTallRows() { var style = TableStyles.Minimal(); - style.AllowRowBreakAcrossPages = false; + style.AllowRowBreakAcrossPages = true; + style.RowAllowBreakAcrossPages = new List { null, false }; style.ColumnWidthPoints = new List { 70, null }; string longValue = string.Join(" ", Enumerable.Range(1, 60).Select(i => "segment" + i.ToString("00"))); @@ -10638,7 +11662,7 @@ public void Table_RejectsInvalidRelativeColumnWidthWeights() { } [Fact] - public void Table_RejectsInvalidFixedColumnWidthPoints() { + public void Table_RejectsInvalidFixedColumnWidthPoints_AndFitsOversizedFixedColumns() { var invalidStyle = TableStyles.Minimal(); var invalidException = Assert.Throws(() => @@ -10649,13 +11673,19 @@ public void Table_RejectsInvalidFixedColumnWidthPoints() { var tooWideStyle = TableStyles.Minimal(); tooWideStyle.ColumnWidthPoints = new List { 400, 400 }; - Assert.Throws(() => - PdfDoc.Create() - .Table(new[] { - new[] { "A", "B" }, - new[] { "1", "2" } - }, style: tooWideStyle) - .ToBytes()); + byte[] bytes = PdfDoc.Create() + .Table(new[] { + new[] { "A", "B" }, + new[] { "1", "2" } + }, style: tooWideStyle) + .ToBytes(); + + using var pdf = PdfDocument.Open(new MemoryStream(bytes)); + var page = pdf.GetPage(1); + double firstColumnX = FindWordStartX(page, "A"); + double secondColumnX = FindWordStartX(page, "B"); + + Assert.InRange(secondColumnX - firstColumnX, 210D, 240D); } [Fact] @@ -10697,6 +11727,13 @@ public void Table_RejectsInvalidMinimumAndMaximumColumnWidthPoints() { Assert.Contains("Table header row count cannot be negative.", invalidHeaderRowsException.Message, StringComparison.Ordinal); + var invalidRepeatHeaderRows = TableStyles.Minimal(); + + var invalidRepeatHeaderRowsException = Assert.Throws(() => + invalidRepeatHeaderRows.RepeatHeaderRowCount = -1); + + Assert.Contains("Table repeating header row count cannot be negative.", invalidRepeatHeaderRowsException.Message, StringComparison.Ordinal); + var invalidFooterRows = TableStyles.Minimal(); var invalidFooterRowsException = Assert.Throws(() => @@ -10711,6 +11748,13 @@ public void Table_RejectsInvalidMinimumAndMaximumColumnWidthPoints() { Assert.Contains("Table minimum row height must be a non-negative finite value.", invalidMinimumRowHeightException.Message, StringComparison.Ordinal); + var invalidRowMinimumHeights = TableStyles.Minimal(); + + var invalidRowMinimumHeightsException = Assert.Throws(() => + invalidRowMinimumHeights.RowMinHeights = new List { 18, double.NaN }); + + Assert.Contains("Table row minimum heights must be non-negative finite values.", invalidRowMinimumHeightsException.Message, StringComparison.Ordinal); + var invalidSpacingBefore = TableStyles.Minimal(); var invalidSpacingBeforeException = Assert.Throws(() => @@ -10953,6 +11997,24 @@ public void Table_RejectsHeaderRowCountBeyondRows() { Assert.Contains("Table header row count cannot exceed the table row count.", exception.Message, StringComparison.Ordinal); } + [Fact] + public void Table_RejectsRepeatHeaderRowCountBeyondHeaderRows() { + var style = TableStyles.Minimal(); + style.HeaderRowCount = 1; + style.RepeatHeaderRowCount = 2; + + var exception = Assert.Throws(() => + PdfDoc.Create() + .Table(new[] { + new[] { "H1", "H2" }, + new[] { "B1", "B2" }, + new[] { "B3", "B4" } + }, style: style) + .ToBytes()); + + Assert.Contains("Table repeating header row count cannot exceed the table header row count.", exception.Message, StringComparison.Ordinal); + } + [Fact] public void Table_RejectsCombinedHeaderAndFooterRowsBeyondRows() { var style = TableStyles.Minimal(); @@ -11115,6 +12177,46 @@ public void ParagraphKeepWithNext_RejectsFollowingTableWithOutOfRangeCellFillCoo Assert.Contains("Table cell fill coordinates must fit inside the table grid.", exception.Message, StringComparison.Ordinal); } + [Fact] + public void Table_RejectsOutOfRangeRowMinimumHeights() { + var style = TableStyles.Minimal(); + style.RowMinHeights = new List { + null, + null, + 24 + }; + + var exception = Assert.Throws(() => + PdfDoc.Create() + .Table(new[] { + new[] { "A", "B" }, + new[] { "1", "2" } + }, style: style) + .ToBytes()); + + Assert.Contains("Table row minimum heights must fit inside the table grid.", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Table_RejectsOutOfRangeRowBreakPolicies() { + var style = TableStyles.Minimal(); + style.RowAllowBreakAcrossPages = new List { + null, + null, + false + }; + + var exception = Assert.Throws(() => + PdfDoc.Create() + .Table(new[] { + new[] { "A", "B" }, + new[] { "1", "2" } + }, style: style) + .ToBytes()); + + Assert.Contains("Table row break policies must fit inside the table grid.", exception.Message, StringComparison.Ordinal); + } + [Fact] public void Table_RejectsOutOfRangeBodyColumnFill() { var style = TableStyles.Minimal(); @@ -11414,6 +12516,13 @@ public void Table_RejectsInvalidStylePrimitiveValues() { Assert.Contains("Table bottom cell padding must be a non-negative finite value.", bottomPaddingException.Message, StringComparison.Ordinal); + var invalidCellSpacing = TableStyles.Minimal(); + + var cellSpacingException = Assert.Throws(() => + invalidCellSpacing.CellSpacing = -1); + + Assert.Contains("Table cell spacing must be a non-negative finite value.", cellSpacingException.Message, StringComparison.Ordinal); + var excessiveHorizontalPadding = TableStyles.Minimal(); excessiveHorizontalPadding.ColumnWidthPoints = new List { 12 }; excessiveHorizontalPadding.CellPaddingX = 6; @@ -11462,6 +12571,66 @@ public void Table_RejectsInvalidStylePrimitiveValues() { }); Assert.Contains("Table cell fill coordinates cannot be negative.", fillException.Message, StringComparison.Ordinal); + + var invalidCellDataBar = TableStyles.Minimal(); + + var dataBarCoordinateException = Assert.Throws(() => + invalidCellDataBar.CellDataBars = new Dictionary<(int Row, int Column), PdfCellDataBar> { + [(-1, 0)] = new PdfCellDataBar { Ratio = 0.5 } + }); + + Assert.Contains("Table cell data bar coordinates cannot be negative.", dataBarCoordinateException.Message, StringComparison.Ordinal); + + var dataBarRatioException = Assert.Throws(() => + new PdfCellDataBar { Ratio = double.NaN }); + + Assert.Contains("PDF table data bar ratio must be a finite value between 0 and 1.", dataBarRatioException.Message, StringComparison.Ordinal); + + var invalidCellIcon = TableStyles.Minimal(); + + var iconCoordinateException = Assert.Throws(() => + invalidCellIcon.CellIcons = new Dictionary<(int Row, int Column), PdfCellIcon> { + [(-1, 0)] = new PdfCellIcon() + }); + + Assert.Contains("Table cell icon coordinates cannot be negative.", iconCoordinateException.Message, StringComparison.Ordinal); + + var iconSizeException = Assert.Throws(() => + new PdfCellIcon { Size = double.PositiveInfinity }); + + Assert.Contains("PDF table cell icon size must be a positive finite value.", iconSizeException.Message, StringComparison.Ordinal); + + var invalidCellPadding = TableStyles.Minimal(); + + var paddingException = Assert.Throws(() => + invalidCellPadding.CellPaddings = new Dictionary<(int Row, int Column), PdfCellPadding> { + [(-1, 0)] = new PdfCellPadding { Left = 4 } + }); + + Assert.Contains("Table cell padding coordinates cannot be negative.", paddingException.Message, StringComparison.Ordinal); + + var invalidCellPaddingValueException = Assert.Throws(() => + new PdfCellPadding { Left = double.NaN }); + + Assert.Contains("Table cell padding values must be non-negative finite values.", invalidCellPaddingValueException.Message, StringComparison.Ordinal); + + var invalidCellAlignment = TableStyles.Minimal(); + + var cellAlignmentException = Assert.Throws(() => + invalidCellAlignment.CellAlignments = new Dictionary<(int Row, int Column), PdfColumnAlign> { + [(-1, 0)] = PdfColumnAlign.Center + }); + + Assert.Contains("Table cell alignment coordinates cannot be negative.", cellAlignmentException.Message, StringComparison.Ordinal); + + var invalidCellVerticalAlignment = TableStyles.Minimal(); + + var cellVerticalAlignmentException = Assert.Throws(() => + invalidCellVerticalAlignment.CellVerticalAlignments = new Dictionary<(int Row, int Column), PdfCellVerticalAlign> { + [(-1, 0)] = PdfCellVerticalAlign.Middle + }); + + Assert.Contains("Table cell vertical alignment coordinates cannot be negative.", cellVerticalAlignmentException.Message, StringComparison.Ordinal); } [Fact] @@ -11543,24 +12712,56 @@ public void Table_RejectsInvalidAlignmentEnumValues() { invalidVerticalAlign.VerticalAlignments = new List { (PdfCellVerticalAlign)99 }); Assert.Contains("Table vertical alignments must be defined PDF cell vertical alignment values.", invalidVerticalAlignException.Message, StringComparison.Ordinal); + + var invalidCellAlign = TableStyles.Minimal(); + + var invalidCellAlignException = Assert.Throws(() => + invalidCellAlign.CellAlignments = new Dictionary<(int Row, int Column), PdfColumnAlign> { + [(0, 0)] = (PdfColumnAlign)99 + }); + + Assert.Contains("Table column alignments must be Left, Center, or Right.", invalidCellAlignException.Message, StringComparison.Ordinal); + + var invalidCellVerticalAlign = TableStyles.Minimal(); + + var invalidCellVerticalAlignException = Assert.Throws(() => + invalidCellVerticalAlign.CellVerticalAlignments = new Dictionary<(int Row, int Column), PdfCellVerticalAlign> { + [(0, 0)] = (PdfCellVerticalAlign)99 + }); + + Assert.Contains("Table vertical alignments must be defined PDF cell vertical alignment values.", invalidCellVerticalAlignException.Message, StringComparison.Ordinal); } [Fact] public void TableStyle_AlignmentListsSnapshotAssignedCollections() { var horizontal = new List { PdfColumnAlign.Left, PdfColumnAlign.Center }; var vertical = new List { PdfCellVerticalAlign.Top, PdfCellVerticalAlign.Middle }; + var cellHorizontal = new Dictionary<(int Row, int Column), PdfColumnAlign> { + [(1, 1)] = PdfColumnAlign.Right + }; + var cellVertical = new Dictionary<(int Row, int Column), PdfCellVerticalAlign> { + [(1, 1)] = PdfCellVerticalAlign.Bottom + }; var style = TableStyles.Minimal(); style.Alignments = horizontal; style.VerticalAlignments = vertical; + style.CellAlignments = cellHorizontal; + style.CellVerticalAlignments = cellVertical; horizontal[0] = PdfColumnAlign.Right; vertical[0] = PdfCellVerticalAlign.Bottom; + cellHorizontal[(1, 1)] = PdfColumnAlign.Center; + cellVertical[(1, 1)] = PdfCellVerticalAlign.Middle; Assert.NotNull(style.Alignments); Assert.NotNull(style.VerticalAlignments); + Assert.NotNull(style.CellAlignments); + Assert.NotNull(style.CellVerticalAlignments); Assert.Equal(PdfColumnAlign.Left, style.Alignments![0]); Assert.Equal(PdfCellVerticalAlign.Top, style.VerticalAlignments![0]); + Assert.Equal(PdfColumnAlign.Right, style.CellAlignments![(1, 1)]); + Assert.Equal(PdfCellVerticalAlign.Bottom, style.CellVerticalAlignments![(1, 1)]); } [Fact] @@ -11569,26 +12770,40 @@ public void TableStyle_ColumnSizingListsSnapshotAssignedCollections() { var minWidths = new List { 40, null }; var maxWidths = new List { null, 120 }; var weights = new List { 1, 2 }; + var rowMinHeights = new List { 18, null, 36 }; + var rowBreakPolicies = new List { false, null, true }; var style = TableStyles.Minimal(); style.ColumnWidthPoints = fixedWidths; style.ColumnMinWidthPoints = minWidths; style.ColumnMaxWidthPoints = maxWidths; style.ColumnWidthWeights = weights; + style.RowMinHeights = rowMinHeights; + style.RowAllowBreakAcrossPages = rowBreakPolicies; fixedWidths[0] = 10; minWidths[0] = 10; maxWidths[1] = 10; weights[1] = 10; + rowMinHeights[0] = 99; + rowBreakPolicies[0] = true; Assert.NotNull(style.ColumnWidthPoints); Assert.NotNull(style.ColumnMinWidthPoints); Assert.NotNull(style.ColumnMaxWidthPoints); Assert.NotNull(style.ColumnWidthWeights); + Assert.NotNull(style.RowMinHeights); + Assert.NotNull(style.RowAllowBreakAcrossPages); Assert.Equal(60, style.ColumnWidthPoints![0]); Assert.Equal(40, style.ColumnMinWidthPoints![0]); Assert.Equal(120, style.ColumnMaxWidthPoints![1]); Assert.Equal(2, style.ColumnWidthWeights![1]); + Assert.Equal(18, style.RowMinHeights![0]); + Assert.Null(style.RowMinHeights![1]); + Assert.Equal(36, style.RowMinHeights![2]); + Assert.False(style.RowAllowBreakAcrossPages![0]); + Assert.Null(style.RowAllowBreakAcrossPages![1]); + Assert.True(style.RowAllowBreakAcrossPages![2]); } [Fact] @@ -11597,32 +12812,108 @@ public void TableStyle_FillAndBorderCollectionsSnapshotAssignedValues() { var cellFills = new Dictionary<(int Row, int Column), PdfColor> { [(1, 1)] = new PdfColor(0.1, 0.2, 0.3) }; + var cellDataBar = new PdfCellDataBar { + Color = new PdfColor(0.2, 0.3, 0.4), + Ratio = 0.25 + }; + var cellDataBars = new Dictionary<(int Row, int Column), PdfCellDataBar> { + [(1, 1)] = cellDataBar + }; + var cellIcon = new PdfCellIcon { + Kind = PdfCellIconKind.Circle, + Color = new PdfColor(0.25, 0.35, 0.45), + Size = 9 + }; + var cellIcons = new Dictionary<(int Row, int Column), PdfCellIcon> { + [(1, 1)] = cellIcon + }; var cellBorder = new PdfCellBorder { Color = new PdfColor(0.4, 0.5, 0.6), Width = 1.25, - Left = false + DashStyle = OfficeStrokeDashStyle.Dash, + LineStyle = PdfCellBorderLineStyle.TwoLine, + Left = false, + DiagonalUp = true, + DiagonalUpBorder = new PdfCellBorderSide { + Color = new PdfColor(0.11, 0.22, 0.33), + Width = 1.75, + LineStyle = PdfCellBorderLineStyle.TwoLine + }, + TopBorder = new PdfCellBorderSide { + Color = new PdfColor(0.7, 0.8, 0.9), + Width = 2.25, + DashStyle = OfficeStrokeDashStyle.Dot, + LineStyle = PdfCellBorderLineStyle.TwoLine + } }; var cellBorders = new Dictionary<(int Row, int Column), PdfCellBorder> { [(1, 1)] = cellBorder }; + var cellPadding = new PdfCellPadding { + Left = 7, + Right = 8, + Top = 9, + Bottom = 10 + }; + var cellPaddings = new Dictionary<(int Row, int Column), PdfCellPadding> { + [(1, 1)] = cellPadding + }; var style = TableStyles.Minimal(); style.BodyColumnFills = columnFills; style.CellFills = cellFills; + style.CellDataBars = cellDataBars; + style.CellIcons = cellIcons; style.CellBorders = cellBorders; + style.CellPaddings = cellPaddings; columnFills[0] = PdfColor.White; cellFills[(1, 1)] = PdfColor.Black; + cellDataBar.Ratio = 0.75; + cellDataBar.Color = PdfColor.Black; + cellIcon.Kind = PdfCellIconKind.Diamond; + cellIcon.Color = PdfColor.Black; + cellIcon.Size = 12; cellBorder.Width = 4; + cellBorder.LineStyle = PdfCellBorderLineStyle.Standard; cellBorder.Left = true; + cellBorder.DiagonalUp = false; + cellBorder.TopBorder = new PdfCellBorderSide { + Color = PdfColor.Black, + Width = 5 + }; + cellPadding.Left = 30; + cellPadding.Top = 31; Assert.NotNull(style.BodyColumnFills); Assert.NotNull(style.CellFills); + Assert.NotNull(style.CellDataBars); + Assert.NotNull(style.CellIcons); Assert.NotNull(style.CellBorders); + Assert.NotNull(style.CellPaddings); Assert.Equal(PdfColor.Gray, style.BodyColumnFills![0]); Assert.Equal(new PdfColor(0.1, 0.2, 0.3), style.CellFills![(1, 1)]); + Assert.Equal(new PdfColor(0.2, 0.3, 0.4), style.CellDataBars![(1, 1)].Color); + Assert.Equal(0.25, style.CellDataBars![(1, 1)].Ratio); + Assert.Equal(PdfCellIconKind.Circle, style.CellIcons![(1, 1)].Kind); + Assert.Equal(new PdfColor(0.25, 0.35, 0.45), style.CellIcons![(1, 1)].Color); + Assert.Equal(9, style.CellIcons![(1, 1)].Size); Assert.Equal(1.25, style.CellBorders![(1, 1)].Width); + Assert.Equal(OfficeStrokeDashStyle.Dash, style.CellBorders![(1, 1)].DashStyle); + Assert.Equal(PdfCellBorderLineStyle.TwoLine, style.CellBorders![(1, 1)].LineStyle); Assert.False(style.CellBorders![(1, 1)].Left); + Assert.True(style.CellBorders![(1, 1)].DiagonalUp); + Assert.Equal(new PdfColor(0.11, 0.22, 0.33), style.CellBorders![(1, 1)].DiagonalUpBorder!.Color); + Assert.Equal(1.75, style.CellBorders![(1, 1)].DiagonalUpBorder!.Width); + Assert.Equal(PdfCellBorderLineStyle.TwoLine, style.CellBorders![(1, 1)].DiagonalUpBorder!.LineStyle); + Assert.Equal(new PdfColor(0.7, 0.8, 0.9), style.CellBorders![(1, 1)].TopBorder!.Color); + Assert.Equal(2.25, style.CellBorders![(1, 1)].TopBorder!.Width); + Assert.Equal(OfficeStrokeDashStyle.Dot, style.CellBorders![(1, 1)].TopBorder!.DashStyle); + Assert.Equal(PdfCellBorderLineStyle.TwoLine, style.CellBorders![(1, 1)].TopBorder!.LineStyle); + Assert.Equal(7, style.CellPaddings![(1, 1)].Left); + Assert.Equal(8, style.CellPaddings![(1, 1)].Right); + Assert.Equal(9, style.CellPaddings![(1, 1)].Top); + Assert.Equal(10, style.CellPaddings![(1, 1)].Bottom); } [Fact] @@ -11643,6 +12934,31 @@ public void TableStyle_TypographySettingsSurviveClone() { style.CellPaddingRight = 8; style.CellPaddingTop = 9; style.CellPaddingBottom = 10; + style.CellPaddings = new Dictionary<(int Row, int Column), PdfCellPadding> { + [(0, 0)] = new PdfCellPadding { Left = 12, Top = 13 } + }; + style.CellDataBars = new Dictionary<(int Row, int Column), PdfCellDataBar> { + [(0, 0)] = new PdfCellDataBar { + Color = new PdfColor(0.2, 0.3, 0.4), + Ratio = 0.5 + } + }; + style.CellIcons = new Dictionary<(int Row, int Column), PdfCellIcon> { + [(0, 0)] = new PdfCellIcon { + Kind = PdfCellIconKind.Diamond, + Color = new PdfColor(0.2, 0.3, 0.4), + Size = 9 + } + }; + style.CellAlignments = new Dictionary<(int Row, int Column), PdfColumnAlign> { + [(0, 0)] = PdfColumnAlign.Right + }; + style.CellVerticalAlignments = new Dictionary<(int Row, int Column), PdfCellVerticalAlign> { + [(0, 0)] = PdfCellVerticalAlign.Bottom + }; + style.CellSpacing = 11; + style.RowMinHeights = new List { 16, null, 48 }; + style.RowAllowBreakAcrossPages = new List { false, null, true }; PdfTableStyle clone = style.Clone(); @@ -11661,6 +12977,57 @@ public void TableStyle_TypographySettingsSurviveClone() { Assert.Equal(8, clone.CellPaddingRight); Assert.Equal(9, clone.CellPaddingTop); Assert.Equal(10, clone.CellPaddingBottom); + Assert.NotNull(clone.CellPaddings); + Assert.Equal(12, clone.CellPaddings![(0, 0)].Left); + Assert.Equal(13, clone.CellPaddings![(0, 0)].Top); + Assert.NotNull(clone.CellDataBars); + Assert.Equal(new PdfColor(0.2, 0.3, 0.4), clone.CellDataBars![(0, 0)].Color); + Assert.Equal(0.5, clone.CellDataBars![(0, 0)].Ratio); + Assert.NotNull(clone.CellIcons); + Assert.Equal(PdfCellIconKind.Diamond, clone.CellIcons![(0, 0)].Kind); + Assert.Equal(new PdfColor(0.2, 0.3, 0.4), clone.CellIcons![(0, 0)].Color); + Assert.Equal(9, clone.CellIcons![(0, 0)].Size); + Assert.NotNull(clone.CellAlignments); + Assert.NotNull(clone.CellVerticalAlignments); + Assert.Equal(PdfColumnAlign.Right, clone.CellAlignments![(0, 0)]); + Assert.Equal(PdfCellVerticalAlign.Bottom, clone.CellVerticalAlignments![(0, 0)]); + Assert.Equal(11, clone.CellSpacing); + Assert.NotNull(clone.RowMinHeights); + Assert.Equal(16, clone.RowMinHeights![0]); + Assert.Null(clone.RowMinHeights![1]); + Assert.Equal(48, clone.RowMinHeights![2]); + Assert.NotNull(clone.RowAllowBreakAcrossPages); + Assert.False(clone.RowAllowBreakAcrossPages![0]); + Assert.Null(clone.RowAllowBreakAcrossPages![1]); + Assert.True(clone.RowAllowBreakAcrossPages![2]); + } + + private static byte[] CreateTableCellSpacingProbe(PdfOptions options, double cellSpacing, bool useRowColumnFlow) { + var style = TableStyles.Minimal(); + style.HeaderRowCount = 0; + style.CellPaddingX = 1; + style.CellPaddingY = 1; + style.CellSpacing = cellSpacing; + style.ColumnWidthPoints = new List { 90, 90 }; + + var rows = new[] { + new[] { "SpacingA1", "SpacingB1" }, + new[] { "SpacingA2", "SpacingB2" } + }; + + if (useRowColumnFlow) { + return PdfDoc.Create(options) + .Compose(compose => + compose.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => column.Table(rows, style: style)))))) + .ToBytes(); + } + + return PdfDoc.Create(options) + .Table(rows, style: style) + .ToBytes(); } private static byte[] CreateTablePaddingProbe(PdfOptions options, bool useRowColumnFlow, bool useSidePadding) { @@ -11695,6 +13062,77 @@ private static byte[] CreateTablePaddingProbe(PdfOptions options, bool useRowCol .ToBytes(); } + private static byte[] CreateTablePerCellPaddingProbe(PdfOptions options, bool useRowColumnFlow, bool useCellPadding) { + var style = TableStyles.Minimal(); + style.HeaderRowCount = 0; + style.CellPaddingX = 0; + style.CellPaddingY = 0; + style.ColumnWidthPoints = new List { 110 }; + if (useCellPadding) { + style.CellPaddings = new Dictionary<(int Row, int Column), PdfCellPadding> { + [(0, 0)] = new PdfCellPadding { + Left = 22, + Right = 3, + Top = 16, + Bottom = 4 + } + }; + } + + var rows = new[] { + new[] { "CellPadMarker" } + }; + + if (useRowColumnFlow) { + return PdfDoc.Create(options) + .Compose(compose => + compose.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => column.Table(rows, style: style)))))) + .ToBytes(); + } + + return PdfDoc.Create(options) + .Table(rows, style: style) + .ToBytes(); + } + + private static byte[] CreateTablePerCellAlignmentProbe(PdfOptions options, bool useRowColumnFlow, bool useCellAlignment) { + var style = TableStyles.Minimal(); + style.HeaderRowCount = 0; + style.CellPaddingX = 2; + style.CellPaddingY = 2; + style.MinRowHeight = 72; + style.ColumnWidthPoints = new List { 130 }; + if (useCellAlignment) { + style.CellAlignments = new Dictionary<(int Row, int Column), PdfColumnAlign> { + [(0, 0)] = PdfColumnAlign.Right + }; + style.CellVerticalAlignments = new Dictionary<(int Row, int Column), PdfCellVerticalAlign> { + [(0, 0)] = PdfCellVerticalAlign.Bottom + }; + } + + var rows = new[] { + new[] { "CellAlignMarker" } + }; + + if (useRowColumnFlow) { + return PdfDoc.Create(options) + .Compose(compose => + compose.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => column.Table(rows, style: style)))))) + .ToBytes(); + } + + return PdfDoc.Create(options) + .Table(rows, style: style) + .ToBytes(); + } + private static byte[] CreateTableLineHeightProbe(PdfOptions options, double? lineHeight, bool useRowColumnFlow) { var style = TableStyles.Minimal(); style.HeaderRowCount = 0; diff --git a/OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs b/OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs index bf6c0c52d..1ee85ebf5 100644 --- a/OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs @@ -1,5 +1,7 @@ +using System.IO; using System.Text; using OfficeIMO.Pdf; +using UglyToad.PdfPig; using Xunit; namespace OfficeIMO.Tests.Pdf; @@ -65,11 +67,13 @@ public void CheckBox_CreatesInspectableAcroFormField() { PdfDocumentPreflight preflight = PdfInspector.Preflight(pdf); Assert.Contains("/AcroForm", raw); + Assert.Contains("/NeedAppearances false", raw); Assert.Contains("/Subtype /Widget", raw); Assert.Contains("/FT /Btn", raw); Assert.Contains("/AS /Yes", raw); Assert.Contains("/AP << /N << /Off", raw); PdfFormField field = Assert.Single(info.FormFields); + Assert.Equal(false, info.AcroFormNeedAppearances); Assert.Equal("AcceptTerms", field.Name); Assert.Equal(PdfFormFieldKind.Button, field.Kind); Assert.True(field.IsCheckBox); @@ -83,6 +87,98 @@ public void CheckBox_CreatesInspectableAcroFormField() { Assert.True(preflight.CanFillAndFlattenSimpleFormFields); } + [Fact] + public void TableCellCheckBox_CreatesInspectableAcroFormFieldInsideCell() { + byte[] pdf = PdfDoc.Create() + .Table(new[] { + new[] { + PdfTableCell.WithCheckBoxes( + "Table approval", + new[] { new PdfTableCellCheckBox("Table.Approved", isChecked: true, size: 12) }) + } + }, style: TableStyles.Light()) + .ToBytes(); + + string raw = Encoding.ASCII.GetString(pdf); + PdfDocumentInfo info = PdfInspector.Inspect(pdf); + + Assert.Contains("/AcroForm", raw); + Assert.Contains("/NeedAppearances false", raw); + PdfFormField field = Assert.Single(info.FormFields); + Assert.Equal("Table.Approved", field.Name); + Assert.Equal(PdfFormFieldKind.Button, field.Kind); + Assert.True(field.IsCheckBox); + Assert.Equal("Yes", field.Value); + PdfFormWidget widget = Assert.Single(field.Widgets); + Assert.Equal(1, widget.PageNumber); + Assert.True(widget.Width >= 11); + Assert.True(widget.Height >= 11); + Assert.True(info.Pages[0].HasFormWidgets); + } + + [Fact] + public void TableCellCheckBox_RendersInlineWithSingleLineText() { + byte[] pdf = PdfDoc.Create(new PdfOptions { + PageSize = new PageSize(300, 180), + Margins = PageMargins.Uniform(24) + }) + .Table(new[] { + new[] { + PdfTableCell.WithCheckBoxes( + "Table approval", + new[] { new PdfTableCellCheckBox("Table.InlineApproved", isChecked: true, size: 12) }) + } + }, style: TableStyles.Light()) + .ToBytes(); + + PdfDocumentInfo info = PdfInspector.Inspect(pdf); + PdfFormWidget widget = Assert.Single(Assert.Single(info.FormFields).Widgets); + using var pdfDocument = PdfDocument.Open(new MemoryStream(pdf)); + var line = FindLine(pdfDocument.GetPage(1), "Table approval"); + double lineEndX = line.Max(letter => letter.EndBaseLine.X); + double baselineY = line[0].StartBaseLine.Y; + double widgetCenterY = (widget.Y1 + widget.Y2) / 2D; + + Assert.True(widget.X1 > lineEndX); + Assert.InRange(widget.X1 - lineEndX, 0D, 12D); + Assert.InRange(widgetCenterY - baselineY, 0D, 6D); + } + + [Fact] + public void TableCellFormFields_CreateInspectableTextAndChoiceFieldsInsideCell() { + byte[] pdf = PdfDoc.Create() + .Table(new[] { + new[] { + PdfTableCell.WithFormFields( + "Table form fields", + new[] { + PdfTableCellFormField.TextField("Table.DueDate", "2026-05-31", width: 140, height: 18), + PdfTableCellFormField.ChoiceField("Table.Country", new[] { "Poland", "Germany" }, value: "Germany", width: 140, height: 18) + }) + } + }, style: TableStyles.Light()) + .ToBytes(); + + string raw = Encoding.ASCII.GetString(pdf); + PdfDocumentInfo info = PdfInspector.Inspect(pdf); + + Assert.Contains("/AcroForm", raw); + Assert.Contains("/Subtype /Widget", raw); + Assert.Equal(2, info.FormFields.Count); + PdfFormField dueDate = Assert.Single(info.FormFields, field => field.Name == "Table.DueDate"); + Assert.Equal(PdfFormFieldKind.Text, dueDate.Kind); + Assert.Equal("2026-05-31", dueDate.Value); + Assert.Equal(1, Assert.Single(dueDate.Widgets).PageNumber); + + PdfFormField country = Assert.Single(info.FormFields, field => field.Name == "Table.Country"); + Assert.Equal(PdfFormFieldKind.Choice, country.Kind); + Assert.True(country.IsChoiceField); + Assert.True(country.IsCombo); + Assert.Equal("Germany", country.Value); + Assert.Equal(new[] { "Poland", "Germany" }, country.Options.Select(option => option.ExportValue).ToArray()); + Assert.True(info.Pages[0].HasFormWidgets); + } + [Fact] public void CheckBox_CanBeFilledAndFlattened() { byte[] pdf = PdfDoc.Create() @@ -108,6 +204,102 @@ public void CheckBox_CanBeFilledAndFlattened() { Assert.Contains("/OfficeIMOForm1 Do", raw); } + [Fact] + public void RadioButtonGroup_CreatesInspectableAcroFormField() { + byte[] pdf = PdfDoc.Create() + .Paragraph(p => p.Text("Generated radio buttons:")) + .RadioButtonGroup("Payment.Method", new[] { "Card", "Cash", "Wire" }, value: "Cash", size: 16, gap: 5, spacingAfter: 12) + .ToBytes(); + + string raw = Encoding.ASCII.GetString(pdf); + PdfDocumentInfo info = PdfInspector.Inspect(pdf); + PdfDocumentPreflight preflight = PdfInspector.Preflight(pdf); + + Assert.Contains("/AcroForm", raw); + Assert.Contains("/FT /Btn", raw); + Assert.Contains("/Ff 49152", raw); + Assert.Contains("/Kids [", raw); + Assert.Contains("/V /Cash", raw); + PdfFormField field = Assert.Single(info.FormFields); + Assert.Equal("Payment.Method", field.Name); + Assert.Equal(PdfFormFieldKind.Button, field.Kind); + Assert.True(field.IsRadioButton); + Assert.True(field.IsNoToggleToOff); + Assert.Equal("Cash", field.Value); + Assert.Equal(3, field.WidgetCount); + Assert.Contains(field.Widgets, widget => widget.AppearanceState == "Cash" && widget.HasNormalAppearanceState("Cash")); + Assert.Equal(2, field.Widgets.Count(widget => widget.AppearanceState == "Off")); + Assert.All(field.Widgets, widget => { + Assert.Equal(1, widget.PageNumber); + Assert.True(widget.HasNormalAppearanceState("Off")); + }); + Assert.True(preflight.CanFillSimpleFormFields); + Assert.True(preflight.CanFlattenSimpleFormFields); + Assert.True(preflight.CanFillAndFlattenSimpleFormFields); + } + + [Fact] + public void RadioButtonGroup_CanBeFilledAndFlattened() { + byte[] pdf = PdfDoc.Create() + .RadioButtonGroup("Payment.Method", new[] { "Card", "Cash", "Wire" }, value: "Card") + .ToBytes(); + + byte[] filled = PdfFormFiller.FillFields(pdf, new Dictionary { + ["Payment.Method"] = "Wire" + }); + PdfFormField filledField = Assert.Single(PdfInspector.Inspect(filled).FormFields); + + Assert.Equal("Wire", filledField.Value); + Assert.Contains(filledField.Widgets, widget => widget.AppearanceState == "Wire"); + Assert.Equal(2, filledField.Widgets.Count(widget => widget.AppearanceState == "Off")); + + byte[] flattened = PdfFormFiller.FillAndFlattenFields(pdf, new Dictionary { + ["Payment.Method"] = "Wire" + }); + string raw = Encoding.ASCII.GetString(flattened); + + Assert.False(PdfInspector.Inspect(flattened).HasReadableFormFields); + Assert.DoesNotContain("/AcroForm", raw); + Assert.DoesNotContain("/Subtype /Widget", raw); + Assert.Contains("/OfficeIMOForm", raw); + } + + [Fact] + public void GeneratedFields_CanStyleAppearances() { + var style = new PdfFormFieldStyle { + BackgroundColor = PdfColor.FromRgb(238, 242, 255), + BorderColor = PdfColor.FromRgb(30, 64, 175), + BorderWidth = 2, + TextColor = PdfColor.FromRgb(127, 29, 29), + MarkColor = PdfColor.FromRgb(22, 101, 52) + }; + + byte[] pdf = PdfDoc.Create() + .TextField("Styled.Name", value: "Ada", style: style) + .CheckBox("Styled.Accept", isChecked: true, style: style) + .ChoiceField("Styled.Country", new[] { "Poland", "Germany" }, value: "Poland", style: style) + .RadioButtonGroup("Styled.Contact", new[] { "Email", "Phone" }, value: "Phone", style: style) + .ToBytes(); + + string raw = Encoding.ASCII.GetString(pdf); + + Assert.Contains("/BC [0.118 0.251 0.686] /BG [0.933 0.949 1]", raw, StringComparison.Ordinal); + Assert.Contains("/Helv 10 Tf 0.498 0.114 0.114 rg", raw, StringComparison.Ordinal); + Assert.Contains("0.118 0.251 0.686 RG 2 w", raw, StringComparison.Ordinal); + Assert.Contains("0.086 0.396 0.204 RG 1.25 w", raw, StringComparison.Ordinal); + Assert.Contains("0.086 0.396 0.204 rg", raw, StringComparison.Ordinal); + Assert.Equal(4, PdfInspector.Inspect(pdf).FormFields.Count); + + byte[] filled = PdfFormFiller.FillFields(pdf, new Dictionary { + ["Styled.Name"] = "Filled" + }); + string filledRaw = Encoding.ASCII.GetString(filled); + + Assert.Contains("<46696C6C6564> Tj", filledRaw, StringComparison.Ordinal); + Assert.Contains("0.118 0.251 0.686 RG 1 w", filledRaw, StringComparison.Ordinal); + Assert.Contains("0.498 0.114 0.114 rg", filledRaw, StringComparison.Ordinal); + } + [Fact] public void ChoiceField_CreatesInspectableAcroFormField() { byte[] pdf = PdfDoc.Create() @@ -271,19 +463,21 @@ public void ComposeRowsAndItems_CanPlaceGeneratedFormFields() { .Column(50, column => column .Paragraph(p => p.Text("Right column")) .CheckBox("Right.Enabled", isChecked: true, size: 14, align: PdfAlign.Center, spacingAfter: 8) - .MultiSelectChoiceField("Right.Countries", new[] { "Poland", "Germany", "United States" }, values: new[] { "Germany" }, width: 120, height: 44))); + .MultiSelectChoiceField("Right.Countries", new[] { "Poland", "Germany", "United States" }, values: new[] { "Germany" }, width: 120, height: 44) + .RadioButtonGroup("Right.Contact", new[] { "Email", "Phone" }, value: "Phone", size: 12, gap: 4))); }))) .ToBytes(); PdfDocumentInfo info = PdfInspector.Inspect(pdf); - Assert.Equal(6, info.FormFields.Count); + Assert.Equal(7, info.FormFields.Count); Assert.Contains(info.FormFields, field => field.Name == "Item.Name" && field.IsTextField && field.Value == "Ada"); Assert.Contains(info.FormFields, field => field.Name == "Element.Accept" && field.IsCheckBox && field.Value == "Yes"); Assert.Contains(info.FormFields, field => field.Name == "Left.Email" && field.IsTextField && field.Value == "left@example.com"); Assert.Contains(info.FormFields, field => field.Name == "Left.Country" && field.IsChoiceField && field.Value == "Poland"); Assert.Contains(info.FormFields, field => field.Name == "Right.Enabled" && field.IsCheckBox && field.Value == "Yes"); Assert.Contains(info.FormFields, field => field.Name == "Right.Countries" && field.IsChoiceField && field.AllowsMultipleSelection && field.Values.SequenceEqual(new[] { "Germany" })); + Assert.Contains(info.FormFields, field => field.Name == "Right.Contact" && field.IsRadioButton && field.Value == "Phone"); PdfFormWidget leftEmail = Assert.Single(info.GetFormWidgets("Left.Email")); PdfFormWidget rightEnabled = Assert.Single(info.GetFormWidgets("Right.Enabled")); @@ -335,10 +529,38 @@ public void GeneratedFields_ValidateFlowGeometry() { Assert.Throws(() => PdfDoc.Create().MultiSelectChoiceField("Countries", new[] { "One" }, values: new[] { "Two" })); Assert.Throws(() => PdfDoc.Create().MultiSelectChoiceField("Countries", new[] { "One" }, values: new[] { "One", "One" })); Assert.Throws(() => PdfDoc.Create().MultiSelectChoiceField("Countries", new[] { "One" }, height: 0)); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup(" ", new[] { "One" })); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", null!)); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", Array.Empty())); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", new[] { "One", "One" })); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", new[] { "One", " " })); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", new[] { "One", "Off" })); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", new[] { "One" }, value: "Two")); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", new[] { "Y\u2713" })); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", new[] { "One" }, size: 0)); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", new[] { "One" }, gap: -1)); + Assert.Throws(() => PdfDoc.Create().RadioButtonGroup("Group", new[] { "One" }, align: PdfAlign.Justify)); + Assert.Throws(() => new PdfFormFieldStyle { BorderWidth = -1 }); Assert.Throws(() => PdfDoc.Create() .TextField("Email") .CheckBox("Email") .ToBytes()); } + + private static List FindLine(UglyToad.PdfPig.Content.Page page, string expectedText) { + foreach (var group in page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .GroupBy(letter => Math.Round(letter.StartBaseLine.Y, 1))) { + var ordered = group.OrderBy(letter => letter.StartBaseLine.X).ToList(); + string text = string.Concat(ordered.Select(letter => letter.Value)); + string normalizedText = text.Replace(" ", string.Empty); + string normalizedExpected = expectedText.Replace(" ", string.Empty); + if (normalizedText.Contains(normalizedExpected, StringComparison.Ordinal)) { + return ordered; + } + } + + throw new InvalidOperationException("Could not find text line '" + expectedText + "' in rendered PDF."); + } } diff --git a/OfficeIMO.Tests/Pdf/PdfInspectorTests.cs b/OfficeIMO.Tests/Pdf/PdfInspectorTests.cs index 77ee25373..f8ac2d25f 100644 --- a/OfficeIMO.Tests/Pdf/PdfInspectorTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfInspectorTests.cs @@ -306,6 +306,70 @@ public void Preflight_AllowsGeneratedPdfForReadAndRewrite() { Assert.Equal("1.4", report.Probe.HeaderVersion); } + [Fact] + public void Validator_ReturnsReadableResultForGeneratedPdf() { + PdfValidationResult result = PdfValidator.Validate(BuildTwoPagePdf()); + + Assert.True(result.IsValid); + Assert.True(result.CanRead); + Assert.True(result.CanRewrite); + Assert.True(result.CanExtractText); + Assert.True(result.CanExtractImages); + Assert.True(result.CanReadLogicalObjects); + Assert.True(result.CanManipulatePages); + Assert.True(result.Can(PdfPreflightCapability.ExtractText)); + Assert.Empty(result.GetCapabilityDiagnostics(PdfPreflightCapability.ExtractText)); + Assert.Equal("1.4", result.HeaderVersion); + Assert.Equal(2, result.PageCount); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.ReadBlockers); + Assert.Empty(result.RewriteBlockers); + Assert.NotNull(result.DocumentInfo); + Assert.Same(result.Preflight.DocumentInfo, result.DocumentInfo); + } + + [Fact] + public void Validator_ReportsMalformedPdfWithoutThrowing() { + byte[] bytes = System.Text.Encoding.ASCII.GetBytes("not a pdf"); + + PdfValidationResult result = PdfValidator.Validate(bytes); + + Assert.False(result.IsValid); + Assert.False(result.CanRead); + Assert.False(result.CanRewrite); + Assert.Equal(0, result.PageCount); + Assert.Null(result.HeaderVersion); + Assert.True(result.HasReadBlocker(PdfReadBlockerKind.MissingHeader)); + Assert.Contains("PDF header was not found.", result.Diagnostics); + Assert.Null(result.DocumentInfo); + } + + [Fact] + public void Validator_ReadsFromPathAndCurrentStreamPosition() { + byte[] bytes = BuildTwoPagePdf(); + byte[] prefix = System.Text.Encoding.ASCII.GetBytes("prefix"); + string path = Path.Combine(Path.GetTempPath(), "officeimo-pdf-validate-" + Guid.NewGuid().ToString("N") + ".pdf"); + + try { + File.WriteAllBytes(path, bytes); + + PdfValidationResult fromPath = PdfValidator.Validate(path); + using var stream = new MemoryStream(prefix.Concat(bytes).ToArray()); + stream.Position = prefix.Length; + PdfValidationResult fromStream = PdfValidator.Validate(stream); + + Assert.True(fromPath.IsValid); + Assert.True(fromStream.IsValid); + Assert.Equal(2, fromPath.PageCount); + Assert.Equal(2, fromStream.PageCount); + Assert.Equal(fromPath.DocumentInfo!.Pages[1].Width, fromStream.DocumentInfo!.Pages[1].Width); + } finally { + if (File.Exists(path)) { + File.Delete(path); + } + } + } + [Fact] public void Preflight_ReportsAnnotationsWithoutBlockingRewrite() { byte[] bytes = BuildAnnotatedPdf(); diff --git a/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs b/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs index 8035050ac..58fad7882 100644 --- a/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs @@ -693,6 +693,68 @@ public void Load_ExposesLinkAnnotationsAsLogicalElements() { Assert.Contains(logical.Elements, element => element.Kind == PdfLogicalElementKind.LinkAnnotation); } + [Fact] + public void Load_ExposesHeadingBookmarkLinksAsLogicalElements() { + byte[] pdf = PdfDoc.Create(new PdfOptions { + PageWidth = 360, + PageHeight = 240, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36 + }) + .H1("Jump to details", linkDestinationName: "Details", linkContents: "Heading jump metadata") + .Spacer(18) + .Bookmark("Details") + .H2("Details") + .ToBytes(); + + PdfLogicalDocument logical = PdfLogicalDocument.Load(pdf, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + + Assert.Contains(logical.Headings, heading => heading.Text == "Jump to details"); + Assert.Contains(logical.NamedDestinations, destination => destination.Name == "Details"); + PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByDestinationName("Details")); + Assert.False(link.IsUriLink); + Assert.True(link.IsNamedDestinationLink); + Assert.Null(link.Uri); + Assert.Equal("Details", link.DestinationName); + Assert.Equal("Heading jump metadata", link.Contents); + Assert.True(link.Width > 0); + Assert.True(link.Height > 0); + } + + [Fact] + public void Load_ExposesTableCellNamedDestinationLinksAsLogicalElements() { + byte[] pdf = PdfDoc.Create(new PdfOptions { + PageWidth = 360, + PageHeight = 240, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36 + }) + .Table(new[] { + new[] { + PdfTableCell.TextCell("Jump to target", linkDestinationName: "TargetCell", linkContents: "Table cell jump"), + PdfTableCell.TextCell("Target cell", namedDestinationName: "TargetCell") + } + }) + .ToBytes(); + + PdfLogicalDocument logical = PdfLogicalDocument.Load(pdf, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + + PdfNamedDestination destination = Assert.Single(logical.NamedDestinations); + Assert.Equal("TargetCell", destination.Name); + PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByDestinationName("TargetCell")); + Assert.True(link.IsNamedDestinationLink); + Assert.Equal("Table cell jump", link.Contents); + Assert.Equal("TargetCell", link.DestinationName); + } + private static string Normalize(string text) { return new string(text.Where(ch => !char.IsWhiteSpace(ch)).ToArray()); } diff --git a/OfficeIMO.Tests/Pdf/PdfRowComposeTests.cs b/OfficeIMO.Tests/Pdf/PdfRowComposeTests.cs index d24dc0cb1..6f67de7dd 100644 --- a/OfficeIMO.Tests/Pdf/PdfRowComposeTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfRowComposeTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Text; using OfficeIMO.Pdf; using Xunit; @@ -122,6 +123,8 @@ public void RowStyle_IsStoredOnModelAndSnapshotsInput() { Gap = 18, SpacingBefore = 7, SpacingAfter = 9, + ColumnSeparatorColor = new PdfColor(0.12, 0.34, 0.56), + ColumnSeparatorWidth = 1.25, KeepTogether = true, KeepWithNext = true }; @@ -138,6 +141,8 @@ public void RowStyle_IsStoredOnModelAndSnapshotsInput() { style.Gap = 4; style.SpacingBefore = 1; style.SpacingAfter = 2; + style.ColumnSeparatorColor = PdfColor.Black; + style.ColumnSeparatorWidth = 0.5; style.KeepTogether = false; style.KeepWithNext = false; @@ -148,6 +153,8 @@ public void RowStyle_IsStoredOnModelAndSnapshotsInput() { Assert.Equal(18, row.Style!.Gap); Assert.Equal(7, row.Style.SpacingBefore); Assert.Equal(9, row.Style.SpacingAfter); + Assert.Equal(new PdfColor(0.12, 0.34, 0.56), row.Style.ColumnSeparatorColor); + Assert.Equal(1.25, row.Style.ColumnSeparatorWidth); Assert.True(row.Style.KeepTogether); Assert.True(row.Style.KeepWithNext); } @@ -207,6 +214,8 @@ public void RowStyle_RejectsInvalidValues() { Assert.Throws(() => new PdfRowStyle { Gap = -1 }); Assert.Throws(() => new PdfRowStyle { SpacingBefore = double.PositiveInfinity }); Assert.Throws(() => new PdfRowStyle { SpacingAfter = -1 }); + Assert.Throws(() => new PdfRowStyle { ColumnSeparatorWidth = double.NaN }); + Assert.Throws(() => new PdfRowStyle { ColumnSeparatorWidth = -1 }); Assert.Throws(() => PdfDoc.Create().Compose(compose => @@ -215,6 +224,34 @@ public void RowStyle_RejectsInvalidValues() { content.Row(row => row.Style(null!)))))); } + [Fact] + public void RowColumnSeparator_RendersBetweenColumns() { + byte[] bytes = PdfDoc.Create(new PdfOptions { + PageWidth = 360, + PageHeight = 180, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFontSize = 10 + }) + .Compose(compose => + compose.Page(page => + page.Content(content => + content.Row(row => row + .Gap(20) + .ColumnSeparator(new PdfColor(0.12, 0.34, 0.56), 1.25) + .Column(50, column => column.Paragraph(paragraph => paragraph.Text("LeftSeparatorMarker"))) + .Column(50, column => column.Paragraph(paragraph => paragraph.Text("RightSeparatorMarker"))))))) + .ToBytes(); + + string rawPdf = Encoding.ASCII.GetString(bytes); + + Assert.Contains("0.12 0.34 0.56 RG", rawPdf, StringComparison.Ordinal); + Assert.Contains("1.25 w", rawPdf, StringComparison.Ordinal); + Assert.Contains("180 150 m 180 ", rawPdf, StringComparison.Ordinal); + } + [Fact] public void RowGap_RejectsWhenGapsExceedContentWidthDuringRender() { var doc = PdfDoc.Create(new PdfOptions { diff --git a/OfficeIMO.Tests/Pdf/PdfTextExtractorPageTests.cs b/OfficeIMO.Tests/Pdf/PdfTextExtractorPageTests.cs index d90158987..e0f72a0c0 100644 --- a/OfficeIMO.Tests/Pdf/PdfTextExtractorPageTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfTextExtractorPageTests.cs @@ -412,6 +412,131 @@ public void ExtractAllTextByPageRanges_WithLayoutOptionsUsesColumnAwareReadingOr AssertColumnAwareTextOrder(text); } + [Fact] + public void ExtractMarkdown_WritesLogicalMarkdownToPathAndStreamsForWrapperPipelines() { + string directory = Path.Combine(Path.GetTempPath(), "officeimo-pdf-markdown-" + Guid.NewGuid().ToString("N")); + string inputPath = Path.Combine(directory, "source.pdf"); + string outputPath = Path.Combine(directory, "markdown", "all.md"); + var options = new PdfTextLayoutOptions { + ForceSingleColumn = true + }; + + try { + Directory.CreateDirectory(directory); + byte[] pdf = BuildMarkdownPdf(); + File.WriteAllBytes(inputPath, pdf); + + string markdown = PdfTextExtractor.ExtractMarkdown(pdf, options); + + Assert.Contains("# Markdown Heading", markdown, StringComparison.Ordinal); + Assert.Contains("Markdownreadbackmarker.", Normalize(markdown), StringComparison.Ordinal); + Assert.Contains("| Code | Name | Qty |", markdown, StringComparison.Ordinal); + Assert.Contains("| A-100 | Alpha | 2 |", markdown, StringComparison.Ordinal); + + PdfTextExtractor.ExtractMarkdown(inputPath, outputPath, options); + Assert.True(File.Exists(outputPath)); + Assert.Contains("# Markdown Heading", File.ReadAllText(outputPath, Encoding.UTF8), StringComparison.Ordinal); + + using var pathOutput = CreateOutputStream(out int pathPrefixLength); + PdfTextExtractor.ExtractMarkdown(inputPath, pathOutput, options); + Assert.Contains("| A-100 | Alpha | 2 |", GetOutputText(pathOutput, pathPrefixLength), StringComparison.Ordinal); + + using var streamInput = BuildPrefixedStream(pdf); + streamInput.Position = 5; + using var streamOutput = CreateOutputStream(out int streamPrefixLength); + PdfTextExtractor.ExtractMarkdown(streamInput, streamOutput, options); + Assert.Contains("Markdownreadbackmarker.", Normalize(GetOutputText(streamOutput, streamPrefixLength)), StringComparison.Ordinal); + + using var byteOutput = CreateOutputStream(out int bytePrefixLength); + PdfTextExtractor.ExtractMarkdown(pdf, byteOutput, options); + Assert.Contains("# Markdown Heading", GetOutputText(byteOutput, bytePrefixLength), StringComparison.Ordinal); + } finally { + if (Directory.Exists(directory)) { + Directory.Delete(directory, recursive: true); + } + } + } + + [Fact] + public void ExtractMarkdownByPageRanges_ReturnsCallerOrderAndWritesMarkdownFiles() { + string directory = Path.Combine(Path.GetTempPath(), "officeimo-pdf-markdown-ranges-" + Guid.NewGuid().ToString("N")); + string inputPath = Path.Combine(directory, "source.pdf"); + var options = new PdfTextLayoutOptions { + ForceSingleColumn = true + }; + + try { + Directory.CreateDirectory(directory); + byte[] pdf = BuildThreePageMarkdownPdf(); + File.WriteAllBytes(inputPath, pdf); + + IReadOnlyList pages = PdfTextExtractor.ExtractMarkdownByPageRanges( + pdf, + options, + null, + PdfPageRange.ParseMany("3,1-2,2")); + + Assert.Equal(4, pages.Count); + Assert.Contains("# Third Page", pages[0], StringComparison.Ordinal); + Assert.Contains("# First Page", pages[1], StringComparison.Ordinal); + Assert.Contains("# Second Page", pages[2], StringComparison.Ordinal); + Assert.Contains("# Second Page", pages[3], StringComparison.Ordinal); + + string selectedDocument = PdfTextExtractor.ExtractMarkdownByPageRangesAsDocument( + pdf, + options, + new PdfLogicalMarkdownOptions { + PageSeparator = "***" + }, + PdfPageRange.ParseMany("2,1")); + + AssertContainsInOrder( + Normalize(selectedDocument), + "#SecondPage", + "***", + "#FirstPage"); + + string outputDirectory = Path.Combine(directory, "path-markdown"); + IReadOnlyList paths = PdfTextExtractor.ExtractMarkdownByPageRanges(inputPath, outputDirectory, options, null, PdfPageRange.ParseMany("3,1-2,2")); + + Assert.Equal(4, paths.Count); + Assert.Equal(Path.Combine(outputDirectory, "source-page-0003.md"), paths[0]); + Assert.Equal(Path.Combine(outputDirectory, "source-page-0001.md"), paths[1]); + Assert.Equal(Path.Combine(outputDirectory, "source-page-0002.md"), paths[2]); + Assert.Equal(Path.Combine(outputDirectory, "source-page-0002-occurrence-0002.md"), paths[3]); + Assert.Contains("# Third Page", File.ReadAllText(paths[0], Encoding.UTF8), StringComparison.Ordinal); + + using var stream = BuildPrefixedStream(pdf); + stream.Position = 5; + string streamOutputDirectory = Path.Combine(directory, "stream-markdown"); + IReadOnlyList streamPaths = PdfTextExtractor.ExtractMarkdownByPageRanges( + stream, + streamOutputDirectory, + "stream-source.pdf", + options, + null, + PdfPageRange.ParseMany("2-3")); + + Assert.Equal(2, streamPaths.Count); + Assert.Equal(Path.Combine(streamOutputDirectory, "stream-source-page-0002.md"), streamPaths[0]); + Assert.Equal(Path.Combine(streamOutputDirectory, "stream-source-page-0003.md"), streamPaths[1]); + Assert.Contains("# Second Page", File.ReadAllText(streamPaths[0], Encoding.UTF8), StringComparison.Ordinal); + + string byteOutputDirectory = Path.Combine(directory, "byte-markdown"); + IReadOnlyList bytePaths = PdfTextExtractor.ExtractMarkdownByPage(pdf, byteOutputDirectory, "byte-source.pdf", options); + + Assert.Equal(3, bytePaths.Count); + Assert.Equal(Path.Combine(byteOutputDirectory, "byte-source-page-0001.md"), bytePaths[0]); + Assert.Equal(Path.Combine(byteOutputDirectory, "byte-source-page-0002.md"), bytePaths[1]); + Assert.Equal(Path.Combine(byteOutputDirectory, "byte-source-page-0003.md"), bytePaths[2]); + Assert.Contains("# First Page", File.ReadAllText(bytePaths[0], Encoding.UTF8), StringComparison.Ordinal); + } finally { + if (Directory.Exists(directory)) { + Directory.Delete(directory, recursive: true); + } + } + } + [Fact] public void ExtractTextByPage_RejectsInvalidInputs() { Assert.Throws(() => PdfTextExtractor.ExtractTextByPage((byte[])null!)); @@ -431,17 +556,38 @@ public void ExtractTextByPage_RejectsInvalidInputs() { Assert.Throws(() => PdfTextExtractor.ExtractAllText(BuildThreePagePdf(), (string)null!)); Assert.Throws(() => PdfTextExtractor.ExtractAllText(new MemoryStream(), " ")); Assert.Throws(() => PdfTextExtractor.ExtractAllText(BuildThreePagePdf(), " ")); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown((byte[])null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown((string)null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(" ")); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown((Stream)null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown((string)null!, new MemoryStream())); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown("input.pdf", (Stream)null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown("input.pdf", (string)null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown("input.pdf", " ")); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown((Stream)null!, new MemoryStream())); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(new MemoryStream(), (Stream)null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown((byte[])null!, new MemoryStream())); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(BuildThreePagePdf(), (Stream)null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown((Stream)null!, "out.md")); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(new MemoryStream(), (string)null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown((byte[])null!, "out.md")); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(BuildThreePagePdf(), (string)null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(BuildThreePagePdf(), " ")); Assert.Throws(() => PdfTextExtractor.ExtractTextByPage((string)null!, "out")); Assert.Throws(() => PdfTextExtractor.ExtractTextByPage("input.pdf", (string)null!)); Assert.Throws(() => PdfTextExtractor.ExtractTextByPage("input.pdf", " ")); using var unreadable = new WriteOnlyStream(); Assert.Throws(() => PdfTextExtractor.ExtractTextByPage(unreadable)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(unreadable)); using var readOnlyOutput = new ReadOnlyStream(); Assert.Throws(() => PdfTextExtractor.ExtractAllText("input.pdf", readOnlyOutput)); Assert.Throws(() => PdfTextExtractor.ExtractAllText(new MemoryStream(BuildThreePagePdf()), new ReadOnlyStream())); Assert.Throws(() => PdfTextExtractor.ExtractAllText(BuildThreePagePdf(), new ReadOnlyStream())); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown("input.pdf", new ReadOnlyStream())); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(new MemoryStream(BuildThreePagePdf()), new ReadOnlyStream())); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdown(BuildThreePagePdf(), new ReadOnlyStream())); } [Fact] @@ -475,6 +621,22 @@ public void ExtractTextByPageRanges_RejectsInvalidInputs() { Assert.Throws(() => PdfTextExtractor.ExtractAllTextByPageRanges(new MemoryStream(pdf), (string)null!, PdfPageRange.From(1, 1))); Assert.Throws(() => PdfTextExtractor.ExtractAllTextByPageRanges((byte[])null!, "out.txt", PdfPageRange.From(1, 1))); Assert.Throws(() => PdfTextExtractor.ExtractAllTextByPageRanges(pdf, (string)null!, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges((byte[])null!, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges(pdf, (PdfPageRange[])null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges(pdf, Array.Empty())); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges(pdf, default(PdfPageRange))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges(pdf, PdfPageRange.From(1, 4))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges((string)null!, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges((Stream)null!, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRangesAsDocument((byte[])null!, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRangesAsDocument(pdf, (PdfPageRange[])null!)); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRangesAsDocument(pdf, Array.Empty())); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRangesAsDocument(pdf, PdfPageRange.From(1, 4))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRangesAsDocument((string)null!, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRangesAsDocument((Stream)null!, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges((string)null!, "out", PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges("input.pdf", (string)null!, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges("input.pdf", " ", PdfPageRange.From(1, 1))); using var unreadable = new WriteOnlyStream(); Assert.Throws(() => PdfTextExtractor.ExtractTextByPageRanges(unreadable, PdfPageRange.From(1, 1))); @@ -482,6 +644,8 @@ public void ExtractTextByPageRanges_RejectsInvalidInputs() { Assert.Throws(() => PdfTextExtractor.ExtractAllTextByPageRanges("input.pdf", new ReadOnlyStream(), PdfPageRange.From(1, 1))); Assert.Throws(() => PdfTextExtractor.ExtractAllTextByPageRanges(new MemoryStream(pdf), new ReadOnlyStream(), PdfPageRange.From(1, 1))); Assert.Throws(() => PdfTextExtractor.ExtractAllTextByPageRanges(pdf, new ReadOnlyStream(), PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRanges(unreadable, PdfPageRange.From(1, 1))); + Assert.Throws(() => PdfTextExtractor.ExtractMarkdownByPageRangesAsDocument(unreadable, PdfPageRange.From(1, 1))); } [Fact] @@ -574,6 +738,52 @@ private static byte[] BuildTwoColumnPdf() { .ToBytes(); } + private static byte[] BuildMarkdownPdf() { + return PdfDoc.Create(new PdfOptions { + PageWidth = 420, + PageHeight = 360, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36, + DefaultFontSize = 10 + }) + .H1("Markdown Heading") + .Paragraph(p => p.Text("Markdown readback marker.")) + .Table(new[] { + new[] { "Code", "Name", "Qty" }, + new[] { "A-100", "Alpha", "2" }, + new[] { "B-200", "Beta", "14" } + }, style: new PdfTableStyle { + ColumnWidthPoints = new List { 70, 170, 60 }, + HeaderRowCount = 1, + CellPaddingX = 6, + CellPaddingY = 4 + }) + .ToBytes(); + } + + private static byte[] BuildThreePageMarkdownPdf() { + return PdfDoc.Create(new PdfOptions { + PageWidth = 300, + PageHeight = 220, + MarginLeft = 30, + MarginRight = 30, + MarginTop = 30, + MarginBottom = 30, + DefaultFontSize = 10 + }) + .H1("First Page") + .Paragraph(p => p.Text("First markdown marker.")) + .PageBreak() + .H1("Second Page") + .Paragraph(p => p.Text("Second markdown marker.")) + .PageBreak() + .H1("Third Page") + .Paragraph(p => p.Text("Third markdown marker.")) + .ToBytes(); + } + private static MemoryStream BuildPrefixedStream(byte[] pdf) { var data = new byte[pdf.Length + 5]; data[0] = 1; diff --git a/OfficeIMO.Tests/Pdf/RichParagraphWrappingTests.cs b/OfficeIMO.Tests/Pdf/RichParagraphWrappingTests.cs index 5608ee37f..919e35fb5 100644 --- a/OfficeIMO.Tests/Pdf/RichParagraphWrappingTests.cs +++ b/OfficeIMO.Tests/Pdf/RichParagraphWrappingTests.cs @@ -135,6 +135,23 @@ public void WriterFontSelection_UsesRequestedFontFamilyForGeneratedPdfResources( Assert.DoesNotContain("/BaseFont /Courier", content); } + [Fact] + public void WriterFontSelection_UsesRunFontForGeneratedPdfResources() { + var pdf = PdfDoc.Create() + .Paragraph(p => p + .Text("Default ") + .Font(PdfStandardFont.Courier) + .Text("Code") + .ResetFont() + .Text(" Default")) + .ToBytes(); + + string content = System.Text.Encoding.ASCII.GetString(pdf); + + Assert.Contains("/BaseFont /Helvetica", content); + Assert.Contains("/BaseFont /Courier", content); + } + [Fact] public void WinAnsiEncoding_EncodesSupportedWindows1252CharactersWithoutFallback() { var bytes = PdfWinAnsiEncoding.Encode("\u20AC\u2022\u201C\u201D\u0152\u0178"); diff --git a/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-excel-daily-workbook.page1.poppler.png b/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-excel-daily-workbook.page1.poppler.png new file mode 100644 index 0000000000000000000000000000000000000000..85ed824cc6f63290d48fa8c42593061059d54607 GIT binary patch literal 31081 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 literal 0 HcmV?d00001 diff --git a/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-word-daily-layout.page1.poppler.png b/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-word-daily-layout.page1.poppler.png new file mode 100644 index 0000000000000000000000000000000000000000..40f10388378b33b4b70e7a8e4e007b81a114680a GIT binary patch literal 59406 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 AhX4Qo literal 0 HcmV?d00001 diff --git a/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-word-report.page1.poppler.png b/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-native-word-report.page1.poppler.png new file mode 100644 index 0000000000000000000000000000000000000000..31123beded054211b08b1c4ae04b5668ba073812 GIT binary patch literal 55151 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 z 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%0An*nLf%? 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 z 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 { { "FileFont", fontPath } } - }; - byte[] pdf = document.SaveAsPdf(options); - using (PdfDocument pdfDoc = PdfDocument.Open(new MemoryStream(pdf))) { - var fonts = pdfDoc.GetPage(1).Letters.Select(l => l.FontName).Distinct(); - Assert.Contains(fonts, f => FontNameMatches(f, expectedFont)); - } - } - } - - [Fact] - public void Test_WordDocument_SaveAsPdf_CustomFontStream() { - string fontPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf") - : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? "/System/Library/Fonts/Supplemental/Arial.ttf" - : "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"; - string expectedFont = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? "Arial" - : "DejaVuSans"; - string docPath = Path.Combine(_directoryWithFiles, "PdfFontStream.docx"); - - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello from stream font").FontFamily = "StreamFont"; - document.Save(); - using var fs = File.OpenRead(fontPath); - var options = new PdfSaveOptions { - FontStreams = new Dictionary { { "StreamFont", fs } } - }; - byte[] pdf = document.SaveAsPdf(options); - using (PdfDocument pdfDoc = PdfDocument.Open(new MemoryStream(pdf))) { - var fonts = pdfDoc.GetPage(1).Letters.Select(l => l.FontName).Distinct(); - Assert.Contains(fonts, f => FontNameMatches(f, expectedFont)); - } - } - } - - [Fact] - public void Test_WordDocument_SaveAsPdf_CustomFontFile_CanRetryAfterMissingPath() { - string fontPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf") - : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? "/System/Library/Fonts/Supplemental/Arial.ttf" - : "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"; - string expectedFont = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? "Arial" - : "DejaVuSans"; - string docPath = Path.Combine(_directoryWithFiles, "PdfFontRetry.docx"); - string fontAlias = "RetryFont" + Guid.NewGuid().ToString("N"); - string missingPath = Path.Combine(_directoryWithFiles, "missing-font-file.ttf"); - - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello from retried file font").FontFamily = fontAlias; - document.Save(); - - byte[] firstPdf = document.SaveAsPdf(new PdfSaveOptions { - FontFilePaths = new Dictionary { { fontAlias, missingPath } } - }); - Assert.NotEmpty(firstPdf); - - byte[] secondPdf = document.SaveAsPdf(new PdfSaveOptions { - FontFilePaths = new Dictionary { { fontAlias, fontPath } } - }); - - using (PdfDocument pdfDoc = PdfDocument.Open(new MemoryStream(secondPdf))) { - var fonts = pdfDoc.GetPage(1).Letters.Select(l => l.FontName).Distinct(); - Assert.Contains(fonts, f => FontNameMatches(f, expectedFont)); - } - } - } - - [Fact] - public void Test_WordDocument_SaveAsPdf_CustomFontStream_RewindsSourceAfterFailure() { - string docPath = Path.Combine(_directoryWithFiles, "PdfFontStreamFailure.docx"); - - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello from invalid stream font").FontFamily = "BrokenStreamFont"; - document.Save(); - - using MemoryStream invalidFontStream = new MemoryStream(new byte[] { 1, 2, 3, 4 }); - Assert.ThrowsAny(() => document.SaveAsPdf(new PdfSaveOptions { - FontStreams = new Dictionary { { "BrokenStreamFont", invalidFontStream } } - })); - - Assert.Equal(0, invalidFontStream.Position); - } - } - - private static bool FontNameMatches(string? actualFontName, string expectedFont) { - if (string.IsNullOrWhiteSpace(actualFontName)) { - return false; - } - - string normalizedExpected = NormalizeFontName(expectedFont); - string normalizedActual = NormalizeFontName(actualFontName!); - return normalizedActual.Contains(normalizedExpected, StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizeFontName(string fontName) { - int subsetSeparator = fontName.IndexOf('+'); - string trimmed = subsetSeparator >= 0 ? fontName.Substring(subsetSeparator + 1) : fontName; - return string.Concat(trimmed.Where(char.IsLetterOrDigit)); - } - } -} diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs index 2bc01336a..aff336c60 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs @@ -28,5 +28,58 @@ public void SaveAsPdf_Renders_Footnotes_And_PageNumbers() { Assert.Equal(1, pdf.NumberOfPages); } } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Footnote_Markers_And_Text() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeFootnotes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeFootnotes.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph first = document.AddParagraph("Native footnote here"); + first.AddFootNote("Native footnote text"); + document.AddParagraph("Native after footnote"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (var pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(p => p.Text)); + Assert.Contains("Native footnote here1", allText); + Assert.Contains("1 Native footnote text", Regex.Replace(allText, @"\s+", " ")); + Assert.Contains("Native after footnote", allText); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Endnote_Markers_And_Text() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeEndnotes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeEndnotes.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph footnoteParagraph = document.AddParagraph("Native footnote here"); + footnoteParagraph.AddFootNote("Native footnote text"); + WordParagraph endnoteParagraph = document.AddParagraph("Native endnote here"); + endnoteParagraph.AddEndNote("Native endnote text"); + document.AddParagraph("Native after notes"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (var pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(p => p.Text)); + string normalizedText = Regex.Replace(allText, @"\s+", " "); + Assert.Contains("Native footnote here1", allText); + Assert.Contains("Native endnote here2", allText); + Assert.Contains("1 Native footnote text", normalizedText); + Assert.Contains("2 Native endnote text", normalizedText); + Assert.Contains("Native after notes", allText); + } + } } } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs index 0662a361c..ef34209e5 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs @@ -1,8 +1,13 @@ +using DocumentFormat.OpenXml.Wordprocessing; using OfficeIMO.Word; using OfficeIMO.Word.Pdf; using System; +using System.Globalization; using System.IO; +using System.Linq; using System.Text; +using System.Text.RegularExpressions; +using UglyToad.PdfPig; using Xunit; namespace OfficeIMO.Tests; @@ -26,4 +31,114 @@ public void Test_WordDocument_SaveAsPdf_ImagesAndHyperlinks() { Assert.Contains("/Subtype /Image", pdfContent); Assert.Contains("/URI (https://evotec.xyz", pdfContent); } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Aligned_Images() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeAlignedImages.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeAlignedImages.pdf"); + string imagePath = Path.Combine(_directoryWithImages, "EvotecLogo.png"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph left = document.AddParagraph(); + left.ParagraphAlignment = JustificationValues.Left; + left.AddImage(imagePath, 48, 48); + + WordParagraph center = document.AddParagraph(); + center.ParagraphAlignment = JustificationValues.Center; + center.AddImage(imagePath, 48, 48); + + WordParagraph right = document.AddParagraph(); + right.ParagraphAlignment = JustificationValues.Right; + right.AddImage(imagePath, 48, 48); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(300, 260), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + }); + } + + string pdfContent = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Match mediaBox = Regex.Match(pdfContent, @"/MediaBox\s*\[0 0 (?\d+(?:\.\d+)?) (?\d+(?:\.\d+)?)\]"); + Assert.True(mediaBox.Success, "Expected generated PDF to expose a simple MediaBox."); + + double pageWidth = double.Parse(mediaBox.Groups["width"].Value, CultureInfo.InvariantCulture); + const double margin = 30D; + const double imageWidth = 36D; + double[] imageXPositions = Regex.Matches(pdfContent, @"36 0 0 36 (?-?\d+(?:\.\d+)?) -?\d+(?:\.\d+)? cm\s*/Im\d+ Do") + .Cast() + .Select(match => double.Parse(match.Groups["x"].Value, CultureInfo.InvariantCulture)) + .ToArray(); + + Assert.True(imageXPositions.Length >= 3, "Expected three native image placement matrices."); + Assert.InRange(imageXPositions[0], margin - 1D, margin + 1D); + Assert.InRange(imageXPositions[1], margin + ((pageWidth - (2D * margin) - imageWidth) / 2D) - 1D, margin + ((pageWidth - (2D * margin) - imageWidth) / 2D) + 1D); + Assert.InRange(imageXPositions[2], pageWidth - margin - imageWidth - 1D, pageWidth - margin - imageWidth + 1D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Body_PictureControl_To_Image() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativePictureControl.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativePictureControl.pdf"); + string imagePath = Path.Combine(_directoryWithImages, "EvotecLogo.png"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Logo content control:"); + WordParagraph picture = document.AddParagraph(); + picture.ParagraphAlignment = JustificationValues.Center; + picture.AddPictureControl(imagePath, 48, 48, "Logo", "LogoTag"); + + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyContentControlUnsupported" && + warning.Source == "body paragraph"); + + string pdfContent = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains("/Subtype /Image", pdfContent); + Assert.Contains("36 0 0 36", pdfContent); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Cell_PictureControl_To_Image() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellPictureControl.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellPictureControl.pdf"); + string imagePath = Path.Combine(_directoryWithImages, "EvotecLogo.png"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1, WordTableStyle.TableGrid); + table.WidthType = TableWidthUnitValues.Dxa; + table.Width = 7200; + table.ColumnWidth = new[] { 7200 }.ToList(); + table.ColumnWidthType = TableWidthUnitValues.Dxa; + WordParagraph paragraph = table.Rows[0].Cells[0].Paragraphs[0]; + paragraph.Text = "Logo content control in a table"; + paragraph.ParagraphAlignment = JustificationValues.Center; + paragraph.AddPictureControl(imagePath, 48, 48, "Cell Logo", "CellLogo"); + + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyContentControlUnsupported" && + warning.Source == "body table"); + + byte[] bytes = File.ReadAllBytes(pdfPath); + string pdfContent = Encoding.ASCII.GetString(bytes); + Assert.Contains("/Subtype /Image", pdfContent); + Assert.Contains("36 0 0 36", pdfContent); + + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.Contains("Logo content control in a table", pdf.GetPage(1).Text); + } } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.License.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.License.cs deleted file mode 100644 index 084690840..000000000 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.License.cs +++ /dev/null @@ -1,152 +0,0 @@ -using OfficeIMO.Word; -using OfficeIMO.Word.Pdf; -using QuestPDF.Infrastructure; -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace OfficeIMO.Tests { - public partial class Word { - [Fact] - public void SaveAsPdf_DoesNotOverwriteExistingLicense() { - string docPath = Path.Combine(_directoryWithFiles, "PdfPreSetLicense.docx"); - string pdfPath = Path.Combine(_directoryWithFiles, "PdfPreSetLicense.pdf"); - - QuestPDF.Settings.License = LicenseType.Enterprise; - try { - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello World"); - document.Save(); - document.SaveAsPdf(pdfPath, new PdfSaveOptions { - QuestPdfLicenseType = LicenseType.Community - }); - } - - Assert.True(File.Exists(pdfPath)); - // License behavior is environment-dependent; ensure PDF exists and license stays set - Assert.NotNull(QuestPDF.Settings.License); - } finally { - QuestPDF.Settings.License = null; - } - } - - [Fact] - public void SaveAsPdf_EmbedsCustomFont() { - string docPath = Path.Combine(_directoryWithFiles, "PdfFontFamily.docx"); - string pdfPath = Path.Combine(_directoryWithFiles, "PdfFontFamily.pdf"); - - string fontFamily = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "DejaVu Sans" : "Arial"; - - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello World"); - document.Save(); - document.SaveAsPdf(pdfPath, new PdfSaveOptions { - FontFamily = fontFamily - }); - } - - string pdfContent = File.ReadAllText(pdfPath); - Assert.Contains(fontFamily.Replace(" ", ""), pdfContent, StringComparison.OrdinalIgnoreCase); - Assert.Contains("FontFile", pdfContent); - } - - [Fact] - public async Task SaveAsPdfAsync_Path_RestoresUnsetLicense() { - string docPath = Path.Combine(_directoryWithFiles, "PdfAsyncLicensePath.docx"); - string pdfPath = Path.Combine(_directoryWithFiles, "PdfAsyncLicensePath.pdf"); - - QuestPDF.Settings.License = null; - try { - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello World"); - document.Save(); - - await document.SaveAsPdfAsync(pdfPath, new PdfSaveOptions { - QuestPdfLicenseType = LicenseType.Community - }, CancellationToken.None); - } - - Assert.True(File.Exists(pdfPath)); - Assert.Null(QuestPDF.Settings.License); - } finally { - QuestPDF.Settings.License = null; - } - } - - [Fact] - public async Task SaveAsPdfAsync_ByteArray_RestoresUnsetLicense() { - string docPath = Path.Combine(_directoryWithFiles, "PdfAsyncLicenseBytes.docx"); - - QuestPDF.Settings.License = null; - try { - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello World"); - document.Save(); - - byte[] bytes = await document.SaveAsPdfAsync(new PdfSaveOptions { - QuestPdfLicenseType = LicenseType.Community - }, CancellationToken.None); - - Assert.NotEmpty(bytes); - } - - Assert.Null(QuestPDF.Settings.License); - } finally { - QuestPDF.Settings.License = null; - } - } - - [Fact] - public async Task SaveAsPdfAsync_Path_RestoresUnsetLicense_WhenDocumentCreationFails() { - string docPath = Path.Combine(_directoryWithFiles, "PdfAsyncLicensePathFailure.docx"); - string pdfPath = Path.Combine(_directoryWithFiles, "PdfAsyncLicensePathFailure.pdf"); - - QuestPDF.Settings.License = null; - try { - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello World"); - document.Save(); - - using MemoryStream invalidFontStream = new MemoryStream(new byte[] { 1, 2, 3, 4 }); - await Assert.ThrowsAnyAsync(() => document.SaveAsPdfAsync(pdfPath, new PdfSaveOptions { - QuestPdfLicenseType = LicenseType.Community, - FontStreams = new Dictionary { { "BrokenFont", invalidFontStream } } - }, CancellationToken.None)); - } - - Assert.Null(QuestPDF.Settings.License); - } finally { - QuestPDF.Settings.License = null; - } - } - - [Fact] - public async Task SaveAsPdfAsync_Stream_RestoresUnsetLicense_WhenDocumentCreationFails() { - string docPath = Path.Combine(_directoryWithFiles, "PdfAsyncLicenseStreamFailure.docx"); - - QuestPDF.Settings.License = null; - try { - using (WordDocument document = WordDocument.Create(docPath)) { - document.AddParagraph("Hello World"); - document.Save(); - - using MemoryStream output = new MemoryStream(); - using MemoryStream invalidFontStream = new MemoryStream(new byte[] { 1, 2, 3, 4 }); - await Assert.ThrowsAnyAsync(() => document.SaveAsPdfAsync(output, new PdfSaveOptions { - QuestPdfLicenseType = LicenseType.Community, - FontStreams = new Dictionary { { "BrokenFont", invalidFontStream } } - }, CancellationToken.None)); - } - - Assert.Null(QuestPDF.Settings.License); - } finally { - QuestPDF.Settings.License = null; - } - } - } -} - diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Metadata.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Metadata.cs index fcc6b1164..f7dd42f17 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Metadata.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Metadata.cs @@ -6,9 +6,9 @@ namespace OfficeIMO.Tests { public partial class Word { [Fact] - public void Test_WordDocument_SaveAsPdf_Metadata() { - string docPath = Path.Combine(_directoryWithFiles, "PdfMetadata.docx"); - string pdfPath = Path.Combine(_directoryWithFiles, "PdfMetadata.pdf"); + public void Test_WordDocument_SaveAsPdf_Metadata() { + string docPath = Path.Combine(_directoryWithFiles, "PdfMetadata.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfMetadata.pdf"); using (WordDocument document = WordDocument.Create(docPath)) { document.BuiltinDocumentProperties.Title = "Pdf Title"; document.BuiltinDocumentProperties.Creator = "Pdf Author"; @@ -24,8 +24,34 @@ public void Test_WordDocument_SaveAsPdf_Metadata() { Assert.Equal("Pdf Title", info.Title); Assert.Equal("Pdf Author", info.Author); Assert.Equal("Pdf Subject", info.Subject); - Assert.Equal("keyword1, keyword2", info.Keywords); - } - } - } -} + Assert.Equal("keyword1, keyword2", info.Keywords); + } + } + + [Fact] + public void Test_WordDocument_SaveAsPdf_OfficeIMOEngine_Metadata() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeMetadata.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeMetadata.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.BuiltinDocumentProperties.Title = "Native Pdf Title"; + document.BuiltinDocumentProperties.Creator = "Native Pdf Author"; + document.BuiltinDocumentProperties.Subject = "Native Pdf Subject"; + document.BuiltinDocumentProperties.Keywords = "native, keyword"; + document.AddParagraph("Native metadata"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions()); + } + + Assert.True(File.Exists(pdfPath)); + + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + var info = pdf.Information; + Assert.Equal("Native Pdf Title", info.Title); + Assert.Equal("Native Pdf Author", info.Author); + Assert.Equal("Native Pdf Subject", info.Subject); + Assert.Equal("native, keyword", info.Keywords); + } + } + } +} diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs index 2f7425bc8..382247dca 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs @@ -1,6 +1,8 @@ +using DocumentFormat.OpenXml.Wordprocessing; using OfficeIMO.Word; using OfficeIMO.Word.Pdf; using System.IO; +using UglyToad.PdfPig; using Xunit; namespace OfficeIMO.Tests { @@ -36,5 +38,119 @@ public void SaveAsPdf_Formats_PageNumbers() { Assert.True(File.Exists(pdfCustom)); } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_PageBreaks() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativePageBreaks.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativePageBreaks.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before native page break"); + document.AddPageBreak(); + document.AddParagraph("After native page break"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + Assert.Equal(2, pdf.NumberOfPages); + Assert.Contains("Before native page break", pdf.GetPage(1).Text); + Assert.Contains("After native page break", pdf.GetPage(2).Text); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Word_Section_PageNumbering() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionPageNumbering.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionPageNumbering.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.Sections[0].AddPageNumbering(3, NumberFormatValues.UpperRoman); + document.AddParagraph("Native section page numbering first page"); + document.AddPageBreak(); + document.AddParagraph("Native section page numbering second page"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions()); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + Assert.Equal(2, pdf.NumberOfPages); + + string page1Text = NormalizeNativePageNumberText(pdf.GetPage(1).Text); + string page2Text = NormalizeNativePageNumberText(pdf.GetPage(2).Text); + + Assert.Contains("Nativesectionpagenumberingfirstpage", page1Text, StringComparison.Ordinal); + Assert.Contains("Nativesectionpagenumberingsecondpage", page2Text, StringComparison.Ordinal); + Assert.Contains("III/IV", page1Text, StringComparison.Ordinal); + Assert.Contains("IV/IV", page2Text, StringComparison.Ordinal); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_PageFields_To_PageTokens() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterPageFields.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterPageFields.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordParagraph footerParagraph = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph("Native field footer "); + footerParagraph.AddPageNumber(includeTotalPages: true, separator: " / "); + document.AddParagraph("Native field footer first page"); + document.AddPageBreak(); + document.AddParagraph("Native field footer second page"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + PageNumberFormat = "AUTO {current}/{total}" + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + Assert.Equal(2, pdf.NumberOfPages); + + string page1Text = NormalizeNativePageNumberText(pdf.GetPage(1).Text); + string page2Text = NormalizeNativePageNumberText(pdf.GetPage(2).Text); + + Assert.Contains("Nativefieldfooter1/2", page1Text, StringComparison.Ordinal); + Assert.Contains("Nativefieldfooter2/2", page2Text, StringComparison.Ordinal); + Assert.DoesNotContain("AUTO", page1Text, StringComparison.Ordinal); + Assert.DoesNotContain("AUTO", page2Text, StringComparison.Ordinal); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_PageField_Formats_To_PageTokenStyle() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterPageFieldFormats.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterPageFieldFormats.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordParagraph footerParagraph = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph("Native roman field footer "); + footerParagraph.AddPageNumber(includeTotalPages: true, format: WordFieldFormat.Roman, separator: " / "); + document.AddParagraph("Native roman field footer first page"); + document.AddPageBreak(); + document.AddParagraph("Native roman field footer second page"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions()); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + Assert.Equal(2, pdf.NumberOfPages); + + string page1Text = NormalizeNativePageNumberText(pdf.GetPage(1).Text); + string page2Text = NormalizeNativePageNumberText(pdf.GetPage(2).Text); + + Assert.Contains("NativeromanfieldfooterI/II", page1Text, StringComparison.Ordinal); + Assert.Contains("NativeromanfieldfooterII/II", page2Text, StringComparison.Ordinal); + } + } + + private static string NormalizeNativePageNumberText(string text) => + string.Concat(text.Where(c => !char.IsWhiteSpace(c))); } } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs index 299be33dd..b656d734f 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs @@ -1,7 +1,12 @@ using OfficeIMO.Word; using OfficeIMO.Word.Pdf; +using OfficeIMO.Pdf; +using DocumentFormat.OpenXml.Wordprocessing; using System.IO; using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; using UglyToad.PdfPig; using Xunit; @@ -53,5 +58,1077 @@ public void SaveAsPdf_Renders_Tables() { Assert.True(a1 >= 0 && b1 > a1 && a2 > b1 && b2 > a2); } } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraphs_Headings_And_Tables() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeContent.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeContent.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native heading").SetStyle(WordParagraphStyles.Heading1); + document.AddParagraph("First native paragraph"); + document.AddParagraph("Second native paragraph").SetBold().SetItalic(); + WordTable table = document.AddTable(2, 2); + table.Rows[0].Cells[0].Paragraphs[0].Text = "N-A1"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "N-B1"; + table.Rows[1].Cells[0].Paragraphs[0].Text = "N-A2"; + table.Rows[1].Cells[1].Paragraphs[0].Text = "N-B2"; + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(p => p.Text)); + Assert.Contains("Native heading", allText); + int first = allText.IndexOf("First native paragraph", StringComparison.Ordinal); + int second = allText.IndexOf("Second native paragraph", StringComparison.Ordinal); + int a1 = allText.IndexOf("N-A1", StringComparison.Ordinal); + int b1 = allText.IndexOf("N-B1", StringComparison.Ordinal); + int a2 = allText.IndexOf("N-A2", StringComparison.Ordinal); + int b2 = allText.IndexOf("N-B2", StringComparison.Ordinal); + Assert.True(first >= 0 && second > first); + Assert.True(a1 >= 0 && b1 > a1 && a2 > b1 && b2 > a2); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Baseline_Formatting() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphBaseline.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphBaseline.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before native baseline formatting"); + WordParagraph superscript = document.AddParagraph("Native paragraph superscript"); + superscript.FontSize = 20; + superscript.SetSuperScript(); + WordParagraph subscript = document.AddParagraph("Native paragraph subscript"); + subscript.FontSize = 20; + subscript.SetSubScript(); + WordParagraph mixed = document.AddParagraph(); + mixed.AddText("Native mixed baseline "); + WordParagraph runSuperscript = mixed.AddText("run superscript"); + runSuperscript.FontSize = 20; + runSuperscript.SetSuperScript(); + mixed.AddText(" "); + WordParagraph runSubscript = mixed.AddText("run subscript"); + runSubscript.FontSize = 20; + runSubscript.SetSubScript(); + document.AddParagraph("After native baseline formatting"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(p => p.Text)); + Assert.Contains("Before native baseline formatting", allText); + Assert.Contains("Native paragraph superscript", allText); + Assert.Contains("Native paragraph subscript", allText); + Assert.Contains("Native mixed baseline", allText); + Assert.Contains("run superscript", allText); + Assert.Contains("run subscript", allText); + Assert.Contains("After native baseline formatting", allText); + } + + string content = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains("7 Ts", content); + Assert.Contains("-3.6 Ts", content); + Assert.Contains("0 Ts", content); + Assert.Contains("/F1 13 Tf", content); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Word_Text_Wrapping_Breaks() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTextWrappingBreaks.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTextWrappingBreaks.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before native text wrapping breaks"); + WordParagraph paragraph = document.AddParagraph("NativeSoftFirst"); + paragraph.AddBreak(); + paragraph.AddText("NativeSoftSecond"); + WordTable table = document.AddTable(1, 1); + WordParagraph cellParagraph = table.Rows[0].Cells[0].Paragraphs[0]; + cellParagraph.Text = string.Empty; + cellParagraph.AddText("CellSoftFirst"); + cellParagraph.AddBreak(); + cellParagraph.AddText("CellSoftSecond"); + document.AddParagraph("After native text wrapping breaks"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(p => p.Text)); + Assert.Contains("NativeSoftFirst", allText); + Assert.Contains("NativeSoftSecond", allText); + Assert.Contains("CellSoftFirst", allText); + Assert.Contains("CellSoftSecond", allText); + + var words = pdf.GetPage(1).GetWords().ToList(); + double paragraphFirstY = Assert.Single(words, word => word.Text == "NativeSoftFirst").BoundingBox.Bottom; + double paragraphSecondY = Assert.Single(words, word => word.Text == "NativeSoftSecond").BoundingBox.Bottom; + double cellFirstY = Assert.Single(words, word => word.Text == "CellSoftFirst").BoundingBox.Bottom; + double cellSecondY = Assert.Single(words, word => word.Text == "CellSoftSecond").BoundingBox.Bottom; + + Assert.True(paragraphFirstY > paragraphSecondY + 8D, $"Expected Word paragraph soft break to move following text to the next line. First y: {paragraphFirstY:0.##}, second y: {paragraphSecondY:0.##}."); + Assert.True(cellFirstY > cellSecondY + 8D, $"Expected Word table cell soft break to move following text to the next line. First y: {cellFirstY:0.##}, second y: {cellSecondY:0.##}."); + Assert.InRange(paragraphFirstY - paragraphSecondY, 10.5D, 14.5D); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Justified_Paragraphs() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeJustifiedParagraph.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeJustifiedParagraph.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph paragraph = document.AddParagraph("Native justified paragraph alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau wraps across multiple visual lines."); + paragraph.ParagraphAlignment = JustificationValues.Both; + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(240, 360), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(24) + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Native justified paragraph", allText); + } + + string content = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Matches(new Regex(@"(?:0\.[1-9]\d*|[1-9]\d*(?:\.\d+)?) Tw"), content); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Linked_Heading_As_Heading_Link() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeLinkedHeading.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeLinkedHeading.pdf"); + const string linkUri = "https://evotec.xyz/native-heading"; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph heading = document.AddParagraph(); + heading.SetStyle(WordParagraphStyles.Heading1); + heading.AddHyperLink("Native linked heading", new System.Uri(linkUri), addStyle: true, tooltip: "Native heading metadata"); + document.AddParagraph("Native body after linked heading"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + + Assert.Contains(logical.Headings, heading => heading.Text == "Native linked heading"); + PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByUri(linkUri)); + Assert.Equal("Native heading metadata", link.Contents); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Bookmark_Linked_Heading_As_Internal_Heading_Link() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeBookmarkLinkedHeading.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeBookmarkLinkedHeading.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native heading bookmark target").AddBookmark("NativeHeadingTarget"); + WordParagraph heading = document.AddParagraph(); + heading.SetStyle(WordParagraphStyles.Heading1); + heading.AddHyperLink("Native bookmark linked heading", "NativeHeadingTarget", addStyle: true, tooltip: "Native bookmark heading metadata"); + document.AddParagraph("Native body after bookmark linked heading"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + + Assert.Contains(logical.Headings, heading => heading.Text == "Native bookmark linked heading"); + Assert.Contains(logical.NamedDestinations, destination => destination.Name == "NativeHeadingTarget"); + PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByDestinationName("NativeHeadingTarget")); + Assert.True(link.IsNamedDestinationLink); + Assert.Equal("Native bookmark heading metadata", link.Contents); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Preserves_Paragraph_Link_Metadata() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphLinkMetadata.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphLinkMetadata.pdf"); + const string linkUri = "https://evotec.xyz/native-paragraph-link"; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native paragraph bookmark target").AddBookmark("NativeParagraphTarget"); + WordParagraph external = document.AddParagraph(); + external.AddHyperLink("Native paragraph external link", new System.Uri(linkUri), addStyle: true, tooltip: "Native paragraph external metadata"); + WordParagraph internalLink = document.AddParagraph(); + internalLink.AddHyperLink("Native paragraph bookmark link", "NativeParagraphTarget", addStyle: true, tooltip: "Native paragraph bookmark metadata"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + + var externalLinks = logical.GetLinksByUri(linkUri).ToList(); + Assert.NotEmpty(externalLinks); + Assert.All(externalLinks, link => Assert.Equal("Native paragraph external metadata", link.Contents)); + Assert.Contains(logical.NamedDestinations, destination => destination.Name == "NativeParagraphTarget"); + var bookmarkLinks = logical.GetLinksByDestinationName("NativeParagraphTarget").ToList(); + Assert.NotEmpty(bookmarkLinks); + Assert.All(bookmarkLinks, link => { + Assert.True(link.IsNamedDestinationLink); + Assert.Equal("Native paragraph bookmark metadata", link.Contents); + }); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Simple_Lists_With_Native_List_Blocks() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeLists.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeLists.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordList bulletList = document.AddList(WordListStyle.Bulleted); + bulletList.AddItem("Native bullet one"); + bulletList.AddItem("Native bullet two"); + + WordList numberedList = document.AddCustomList(); + numberedList.Numbering.AddLevel(new WordListLevel(WordListLevelKind.DecimalDot).SetStartNumberingValue(3)); + numberedList.AddItem("Native step three"); + numberedList.AddItem("Native step four"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + byte[] bytes = File.ReadAllBytes(pdfPath); + var listItems = PdfTextExtractor.ExtractListItemsByPage(bytes) + .SelectMany(page => page.ListItems) + .ToList(); + + Assert.Contains(listItems, item => item.Text == "Native bullet one"); + Assert.Contains(listItems, item => item.Text == "Native bullet two"); + Assert.Contains(listItems, item => item.Marker == "3" && item.Text == "Native step three"); + Assert.Contains(listItems, item => item.Marker == "4" && item.Text == "Native step four"); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Uses_Word_List_Hanging_Indent_In_Native_List_Blocks() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeListHangingIndent.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeListHangingIndent.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordList bulletList = document.AddList(WordListStyle.Bulleted); + WordListLevel level = bulletList.Numbering.Levels[0]; + level.IndentationLeft = 720; + level.IndentationHanging = 360; + bulletList.AddItem("Wrapped native bullet item with enough body text to flow onto a second line so the generated PDF can prove the continuation aligns with the Word text position."); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + var lineGroups = pdf.GetPage(1).Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .GroupBy(letter => Math.Round(letter.StartBaseLine.Y, 1)) + .OrderByDescending(group => group.Key) + .Select(group => group.OrderBy(letter => letter.StartBaseLine.X).ToList()) + .ToList(); + + int bulletLineIndex = lineGroups.FindIndex(line => line.Any(letter => letter.Value == "•")); + Assert.True(bulletLineIndex >= 0, "Expected a native bullet marker in the generated PDF."); + + var bulletLine = lineGroups[bulletLineIndex]; + double bulletX = bulletLine.First(letter => letter.Value == "•").StartBaseLine.X; + double textX = bulletLine.First(letter => letter.Value == "W").StartBaseLine.X; + Assert.InRange(textX - bulletX, 14D, 24D); + + var continuationLine = lineGroups + .Skip(bulletLineIndex + 1) + .First(line => !line.Any(letter => letter.Value == "•")); + double continuationX = continuationLine[0].StartBaseLine.X; + Assert.InRange(continuationX, textX - 1D, textX + 1D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Custom_And_Nested_Word_List_Markers() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeCustomNestedListMarkers.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeCustomNestedListMarkers.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordList list = document.AddCustomList(); + list.Numbering.AddLevel(new WordListLevel(WordListLevelKind.LowerLetterDot)); + list.Numbering.AddLevel(new WordListLevel(WordListLevelKind.LowerRomanDot)); + list.Numbering.Levels[0].IndentationLeft = 720; + list.Numbering.Levels[0].IndentationHanging = 360; + list.Numbering.Levels[1].IndentationLeft = 1440; + list.Numbering.Levels[1].IndentationHanging = 360; + + list.AddItem("Lower alpha item"); + list.AddItem("Nested roman item", 1); + list.AddItem("Second alpha item"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + var page = pdf.GetPage(1); + Assert.Contains("a.Lower alpha item", page.Text, StringComparison.Ordinal); + Assert.Contains("i.Nested roman item", page.Text, StringComparison.Ordinal); + Assert.Contains("b.Second alpha item", page.Text, StringComparison.Ordinal); + + var lineGroups = page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .GroupBy(letter => Math.Round(letter.StartBaseLine.Y, 1)) + .OrderByDescending(group => group.Key) + .Select(group => group.OrderBy(letter => letter.StartBaseLine.X).ToList()) + .ToList(); + + var alphaLine = lineGroups.First(line => string.Concat(line.Select(letter => letter.Value)).IndexOf("Loweralphaitem", StringComparison.Ordinal) >= 0); + var nestedLine = lineGroups.First(line => string.Concat(line.Select(letter => letter.Value)).IndexOf("Nestedromanitem", StringComparison.Ordinal) >= 0); + + Assert.StartsWith("a.", string.Concat(alphaLine.Select(letter => letter.Value)), StringComparison.Ordinal); + Assert.StartsWith("i.", string.Concat(nestedLine.Select(letter => letter.Value)), StringComparison.Ordinal); + Assert.True(nestedLine[0].StartBaseLine.X > alphaLine[0].StartBaseLine.X + 30D, "Expected nested Word list marker to render with deeper indentation."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_TableOfContents_With_Heading_Entries() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableOfContents.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableOfContents.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddTableOfContent(); + document.AddParagraph("Native TOC first heading").SetStyle(WordParagraphStyles.Heading1); + document.AddParagraph("Native TOC first body"); + document.AddPageBreak(); + document.AddParagraph("Native TOC second heading").SetStyle(WordParagraphStyles.Heading2); + document.AddParagraph("Native TOC second body"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Contains("Table of Contents", allText); + Assert.True(CountOccurrences(allText, "Native TOC first heading") >= 2, "Expected the first heading in the TOC and again in body content."); + Assert.True(CountOccurrences(allText, "Native TOC second heading") >= 2, "Expected the second heading in the TOC and again in body content."); + Assert.True(allText.IndexOf("Native TOC first heading", StringComparison.Ordinal) < allText.LastIndexOf("Native TOC first heading", StringComparison.Ordinal)); + Assert.True(allText.IndexOf("Native TOC second heading", StringComparison.Ordinal) < allText.LastIndexOf("Native TOC second heading", StringComparison.Ordinal)); + } + + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + const string firstDestination = "officeimo-heading-native-toc-first-heading"; + const string secondDestination = "officeimo-heading-native-toc-second-heading"; + + Assert.Contains(logical.NamedDestinations, destination => destination.Name == firstDestination); + Assert.Contains(logical.NamedDestinations, destination => destination.Name == secondDestination); + var firstTocLinks = logical.GetLinksByDestinationName(firstDestination).ToList(); + var secondTocLinks = logical.GetLinksByDestinationName(secondDestination).ToList(); + Assert.NotEmpty(firstTocLinks); + Assert.NotEmpty(secondTocLinks); + Assert.All(firstTocLinks, link => Assert.Equal("Table of contents: Native TOC first heading", link.Contents)); + Assert.All(secondTocLinks, link => Assert.Equal("Table of contents: Native TOC second heading", link.Contents)); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Creates_Pdf_Outlines_From_Word_Headings() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeadingOutlines.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeadingOutlines.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native outline root").SetStyle(WordParagraphStyles.Heading1); + document.AddParagraph("Native outline body"); + document.AddParagraph("Native outline child").SetStyle(WordParagraphStyles.Heading2); + document.AddParagraph("Native outline child body"); + document.AddPageBreak(); + document.AddParagraph("Native outline appendix").SetStyle(WordParagraphStyles.Heading1); + document.AddParagraph("Native appendix body"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfDocumentInfo info = PdfInspector.Inspect(bytes); + + Assert.Equal(2, info.Outlines.Count); + Assert.Equal("Native outline root", info.Outlines[0].Title); + Assert.Equal(1, info.Outlines[0].Level); + Assert.Equal(1, info.Outlines[0].PageNumber); + + PdfOutlineItem child = Assert.Single(info.Outlines[0].Children); + Assert.Equal("Native outline child", child.Title); + Assert.Equal(2, child.Level); + Assert.Equal(1, child.PageNumber); + + Assert.Equal("Native outline appendix", info.Outlines[1].Title); + Assert.Equal(1, info.Outlines[1].Level); + Assert.Equal(2, info.Outlines[1].PageNumber); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Normal_Word_Headings_As_Logical_Headings() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeNormalHeading.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeNormalHeading.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native normal heading").SetStyle(WordParagraphStyles.Heading1); + document.AddParagraph("Native body after normal heading"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + + Assert.Contains(logical.Headings, heading => heading.Text == "Native normal heading"); + string rawPdf = Encoding.ASCII.GetString(bytes); + Assert.DoesNotContain("/Helvetica-Bold", rawPdf, StringComparison.Ordinal); + + MethodInfo method = typeof(WordPdfConverterExtensions).GetMethod("CreateNativeWordHeadingStyle", BindingFlags.NonPublic | BindingFlags.Static)!; + PdfHeadingStyle headingStyle = Assert.IsType(method.Invoke(null, new object[] { 1 })); + Assert.True(headingStyle.ApplySpacingBeforeAtTop); + Assert.Equal(24D, headingStyle.SpacingBefore); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_List_Item_Bookmarks_Through_Native_List_Blocks() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeListBookmarks.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeListBookmarks.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordList bulletList = document.AddList(WordListStyle.Bulleted); + bulletList.AddItem("Bookmarked native bullet").AddBookmark("NativeListBookmark"); + bulletList.AddItem("Following native bullet"); + + WordParagraph linkParagraph = document.AddParagraph(); + linkParagraph.AddHyperLink("Jump to native list bookmark", "NativeListBookmark", addStyle: true, tooltip: "Native list bookmark metadata"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + var listItems = PdfTextExtractor.ExtractListItemsByPage(bytes) + .SelectMany(page => page.ListItems) + .ToList(); + + Assert.Contains(listItems, item => item.Text == "Bookmarked native bullet"); + Assert.Contains(listItems, item => item.Text == "Following native bullet"); + Assert.Contains(logical.NamedDestinations, destination => destination.Name == "NativeListBookmark"); + var bookmarkLinks = logical.GetLinksByDestinationName("NativeListBookmark").ToList(); + Assert.NotEmpty(bookmarkLinks); + Assert.All(bookmarkLinks, link => Assert.Equal("Native list bookmark metadata", link.Contents)); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Rich_List_Item_Runs() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeRichListRuns.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeRichListRuns.pdf"); + const string linkUri = "https://evotec.xyz/native-list"; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordList bulletList = document.AddList(WordListStyle.Bulleted); + WordParagraph item = bulletList.AddItem(string.Empty); + item.AddText("ListPlain "); + WordParagraph red = item.AddText("ListRed"); + red.ColorHex = "ff0000"; + item.AddText(" "); + item.AddText("ListBold").SetBold(); + item.AddText(" "); + item.AddText("ListMarked").SetHighlight(HighlightColorValues.Yellow); + item.AddText(" "); + item.AddHyperLink("ListLink", new System.Uri(linkUri), addStyle: true, tooltip: "Native list link metadata"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string content = Encoding.ASCII.GetString(bytes); + int redText = content.IndexOf("<4C697374526564>", StringComparison.Ordinal); + int boldText = content.IndexOf("<4C697374426F6C64>", StringComparison.Ordinal); + int markedText = content.IndexOf("<4C6973744D61726B6564>", StringComparison.Ordinal); + + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string pageText = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Equal(1, CountOccurrences(pageText, "ListPlain")); + Assert.Equal(1, CountOccurrences(pageText, "ListRed")); + Assert.Equal(1, CountOccurrences(pageText, "ListBold")); + Assert.Equal(1, CountOccurrences(pageText, "ListMarked")); + Assert.Equal(1, CountOccurrences(pageText, "ListLink")); + } + + var listItems = PdfTextExtractor.ExtractListItemsByPage(bytes) + .SelectMany(page => page.ListItems) + .ToList(); + PdfLogicalDocument logical = PdfLogicalDocument.Load(bytes, new PdfTextLayoutOptions { + ForceSingleColumn = true + }); + PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByUri(linkUri)); + + Assert.Contains(listItems, item => item.Text == "ListPlain ListRed ListBold ListMarked ListLink"); + Assert.True(redText >= 0, "Expected encoded 'ListRed' text in the native list PDF content stream."); + Assert.True(boldText > redText, "Expected encoded 'ListBold' text after the colored list run."); + Assert.True(markedText > boldText, "Expected encoded 'ListMarked' text after the bold list run."); + Assert.True(content.LastIndexOf("1 0 0 rg", redText, StringComparison.Ordinal) >= 0, "Expected Word list run color to emit a red PDF fill color."); + Assert.True(content.LastIndexOf("/F2 11 Tf", boldText, StringComparison.Ordinal) >= 0, "Expected Word list bold run to use the bold PDF font resource."); + Assert.True(content.LastIndexOf("1 1 0 rg", markedText, StringComparison.Ordinal) >= 0, "Expected Word list run highlight to emit a yellow PDF fill color."); + Assert.Equal("Native list link metadata", link.Contents); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_List_Item_Footnotes_Through_Native_List_Blocks() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeListFootnotes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeListFootnotes.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordList bulletList = document.AddList(WordListStyle.Bulleted); + bulletList.AddItem("Footnoted native list item").AddFootNote("Native list footnote text"); + bulletList.AddItem("Following native list item"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + byte[] bytes = File.ReadAllBytes(pdfPath); + var listItems = PdfTextExtractor.ExtractListItemsByPage(bytes) + .SelectMany(page => page.ListItems) + .ToList(); + + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Contains("Footnoted native list item1", allText); + Assert.Contains("Following native list item", allText); + Assert.Equal(1, CountOccurrences(allText, "Native list footnote text")); + } + + Assert.Contains(listItems, item => item.Text == "Footnoted native list item"); + Assert.DoesNotContain(listItems, item => item.Text == "Footnoted native list item1"); + Assert.Contains(listItems, item => item.Text == "Following native list item"); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Shading_And_Uniform_Borders() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphPanel.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphPanel.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph paragraph = document.AddParagraph("Native shaded panel paragraph"); + paragraph.ShadingFillColorHex = "e6f2ff"; + paragraph.Borders.TopStyle = BorderValues.Single; + paragraph.Borders.BottomStyle = BorderValues.Single; + paragraph.Borders.LeftStyle = BorderValues.Single; + paragraph.Borders.RightStyle = BorderValues.Single; + paragraph.Borders.TopColorHex = "336699"; + paragraph.Borders.BottomColorHex = "336699"; + paragraph.Borders.LeftColorHex = "336699"; + paragraph.Borders.RightColorHex = "336699"; + paragraph.Borders.TopSize = 8; + paragraph.Borders.BottomSize = 8; + paragraph.Borders.LeftSize = 8; + paragraph.Borders.RightSize = 8; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(p => p.Text)); + Assert.Contains("Native shaded panel paragraph", text); + } + + string raw = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.902 0.949 1 rg", raw); + Assert.Contains("0.2 0.4 0.6 RG", raw); + Assert.Contains("1 w", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Word_Horizontal_Line() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHorizontalLine.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHorizontalLine.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before native horizontal line"); + document.AddHorizontalLine(BorderValues.Single, OfficeIMO.Drawing.OfficeColor.Red, size: 16, space: 4); + document.AddParagraph("After native horizontal line"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(300, 180), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string raw = Encoding.ASCII.GetString(bytes); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Contains("Before native horizontal", text); + Assert.Contains("After native horizontal", text); + } + + Assert.Contains("1 0 0 RG", raw); + Assert.Contains("2 w", raw); + Assert.DoesNotContain("Before native horizontal lineAfter native horizontal line", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Bottom_Border_As_Rule() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphBottomBorder.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphBottomBorder.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph paragraph = document.AddParagraph("Native bordered paragraph heading"); + paragraph.Borders.BottomStyle = BorderValues.Single; + paragraph.Borders.BottomColorHex = "336699"; + paragraph.Borders.BottomSize = 12; + paragraph.Borders.BottomSpace = 3; + paragraph.LineSpacingAfterPoints = 8; + + document.AddParagraph("After native bottom border"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string raw = Encoding.ASCII.GetString(bytes); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Contains("Native bordered paragraph", text); + Assert.Contains("After native bottom border", text); + } + + Assert.Contains("0.2 0.4 0.6 RG", raw); + Assert.Contains("1.5 w", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Top_Border_As_Rule() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphTopBorder.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphTopBorder.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before native top border"); + + WordParagraph paragraph = document.AddParagraph("Native top bordered paragraph"); + paragraph.Borders.TopStyle = BorderValues.Single; + paragraph.Borders.TopColorHex = "008000"; + paragraph.Borders.TopSize = 16; + paragraph.Borders.TopSpace = 4; + paragraph.LineSpacingBeforePoints = 8; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string raw = Encoding.ASCII.GetString(bytes); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Contains("Before native top border", text); + Assert.Contains("Native top bordered", text); + Assert.Contains("paragraph", text); + } + + Assert.Contains("0 0.502 0 RG", raw); + Assert.Contains("2 w", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_NonUniform_Paragraph_Borders_As_Panel_Sides() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphSideBorders.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphSideBorders.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph paragraph = document.AddParagraph("Native side bordered paragraph"); + paragraph.Borders.LeftStyle = BorderValues.Single; + paragraph.Borders.LeftColorHex = "ff0000"; + paragraph.Borders.LeftSize = 12; + paragraph.Borders.RightStyle = BorderValues.Single; + paragraph.Borders.RightColorHex = "0000ff"; + paragraph.Borders.RightSize = 20; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string raw = Encoding.ASCII.GetString(bytes); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Contains("Native side bordered", text); + Assert.Contains("paragraph", text); + } + + Assert.Contains("1 0 0 RG", raw); + Assert.Contains("1.5 w", raw); + Assert.Contains("0 0 1 RG", raw); + Assert.Contains("2.5 w", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Tab_Leaders() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphTabs.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeParagraphTabs.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph paragraph = document.AddParagraph("Revenue\t12"); + paragraph.AddTabStop(4320, TabStopValues.Right, TabStopLeaderCharValues.Dot); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 180), + OfficeIMOMargins = PageMargins.Uniform(36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + var page = pdf.GetPage(1); + string text = page.Text; + Assert.Contains("Revenue", text); + Assert.Contains("12", text); + + int dotCount = page.Letters.Count(letter => letter.Value == "."); + Assert.True(dotCount >= 15, $"Expected Word tab stop leaders to render across the native paragraph tab gap. Dot count: {dotCount}."); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Does_Not_Leak_Run_Color_To_Following_Runs() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeRunColorReset.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeRunColorReset.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph paragraph = document.AddParagraph(); + paragraph.AddText("Before "); + WordParagraph redRun = paragraph.AddText("Red"); + redRun.ColorHex = "ff0000"; + paragraph.AddText("After"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string content = Encoding.ASCII.GetString(bytes); + int redText = content.IndexOf("<526564>", StringComparison.Ordinal); + int afterText = content.IndexOf("<4166746572>", StringComparison.Ordinal); + + Assert.True(redText >= 0, "Expected encoded 'Red' text in the generated PDF content stream."); + Assert.True(afterText > redText, "Expected encoded 'After' text after the red Word run."); + + int redColorBeforeRed = content.LastIndexOf("1 0 0 rg", redText, StringComparison.Ordinal); + int blackColorBeforeAfter = content.LastIndexOf("0 0 0 rg", afterText, StringComparison.Ordinal); + int redColorBeforeAfter = content.LastIndexOf("1 0 0 rg", afterText, StringComparison.Ordinal); + + Assert.True(redColorBeforeRed >= 0, "Expected the Word run color to emit a red PDF fill color."); + Assert.True(blackColorBeforeAfter > redText, "Expected the following uncolored Word run to reset to black/default PDF fill color."); + Assert.True(redColorBeforeAfter < blackColorBeforeAfter, "Expected the following Word run not to inherit the previous red fill color."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Run_And_Paragraph_Font_Sizes() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeRunFontSizes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeRunFontSizes.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("ParagraphSized").SetFontSize(16); + + WordParagraph paragraph = document.AddParagraph(); + paragraph.AddText("Small"); + paragraph.AddText("Large").SetFontSize(18); + paragraph.AddText("Normal"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string content = Encoding.ASCII.GetString(bytes); + int paragraphText = content.IndexOf("<50617261677261706853697A6564>", StringComparison.Ordinal); + int smallText = content.IndexOf("<536D616C6C>", StringComparison.Ordinal); + int largeText = content.IndexOf("<4C61726765>", StringComparison.Ordinal); + int normalText = content.IndexOf("<4E6F726D616C>", StringComparison.Ordinal); + + Assert.True(paragraphText >= 0, "Expected encoded 'ParagraphSized' text in the native PDF content stream."); + Assert.True(smallText > paragraphText, "Expected encoded 'Small' text after the paragraph-sized paragraph."); + Assert.True(largeText > smallText, "Expected encoded 'Large' text after the default-sized run."); + Assert.True(normalText > largeText, "Expected encoded 'Normal' text after the large run."); + + int paragraphSizeBeforeParagraph = content.LastIndexOf("/F1 16 Tf", paragraphText, StringComparison.Ordinal); + int defaultSizeBeforeSmall = content.LastIndexOf("/F1 11 Tf", smallText, StringComparison.Ordinal); + int largeSizeBeforeLarge = content.LastIndexOf("/F1 18 Tf", largeText, StringComparison.Ordinal); + int defaultSizeBeforeNormal = content.LastIndexOf("/F1 11 Tf", normalText, StringComparison.Ordinal); + + Assert.True(paragraphSizeBeforeParagraph >= 0, "Expected paragraph FontSize to emit a 16-point native PDF run."); + Assert.True(defaultSizeBeforeSmall > paragraphText, "Expected the next paragraph to return to the default font size."); + Assert.True(largeSizeBeforeLarge > smallText, "Expected the Word run font size to emit an 18-point native PDF run."); + Assert.True(defaultSizeBeforeNormal > largeText, "Expected following Word runs not to inherit the previous run font size."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Run_Highlight_To_Background() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeRunHighlight.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeRunHighlight.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph paragraph = document.AddParagraph(); + paragraph.AddText("Before "); + paragraph.AddText("Marked").SetHighlight(HighlightColorValues.Yellow); + paragraph.AddText("After"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string content = Encoding.ASCII.GetString(bytes); + int markedText = content.IndexOf("<4D61726B6564>", StringComparison.Ordinal); + int afterText = content.IndexOf("<4166746572>", StringComparison.Ordinal); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string pageText = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Equal(1, CountOccurrences(pageText, "Before")); + Assert.Equal(1, CountOccurrences(pageText, "Marked")); + Assert.Equal(1, CountOccurrences(pageText, "After")); + } + + Assert.True(markedText >= 0, "Expected encoded 'Marked' text in the native PDF content stream."); + Assert.True(afterText > markedText, "Expected encoded 'After' text after the highlighted Word run."); + + int highlightFill = content.LastIndexOf("1 1 0 rg", markedText, StringComparison.Ordinal); + int highlightRect = content.LastIndexOf(" re f", markedText, StringComparison.Ordinal); + + Assert.True(highlightFill >= 0, "Expected Word run highlight to emit a yellow PDF fill color."); + Assert.True(highlightRect > highlightFill, "Expected Word run highlight to emit a filled rectangle behind the text."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Document_Background_Color() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeDocumentBackground.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeDocumentBackground.pdf"); + string marker = "WordBackgroundPdfMarker"; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.Background.SetColorHex("EAF4FF"); + document.AddParagraph(marker); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string content = Encoding.ASCII.GetString(bytes); + string markerHex = BitConverter.ToString(Encoding.ASCII.GetBytes(marker)).Replace("-", string.Empty); + int backgroundFill = content.IndexOf("0.918 0.957 1 rg", StringComparison.Ordinal); + int backgroundRect = backgroundFill < 0 ? -1 : content.IndexOf(" re f", backgroundFill, StringComparison.Ordinal); + int markerText = content.IndexOf("<" + markerHex + ">", StringComparison.Ordinal); + + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string pageText = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Contains(marker, pageText, StringComparison.Ordinal); + } + + Assert.True(backgroundFill >= 0, "Expected Word document background color to emit a PDF page fill color."); + Assert.True(backgroundRect > backgroundFill, "Expected Word document background color to emit a filled page rectangle."); + Assert.True(markerText > backgroundRect, "Expected document background to render before Word paragraph text."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Cell_Rich_Runs() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellRichRuns.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellRichRuns.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1); + WordParagraph paragraph = table.Rows[0].Cells[0].Paragraphs[0]; + paragraph.Text = string.Empty; + paragraph.AddText("CellPlain "); + WordParagraph red = paragraph.AddText("CellRed"); + red.ColorHex = "ff0000"; + paragraph.AddText(" "); + paragraph.AddText("CellBold").SetBold(); + paragraph.AddText(" "); + paragraph.AddText("CellMarked").SetHighlight(HighlightColorValues.Yellow); + paragraph.AddText(" "); + paragraph.AddText("CellLarge").SetFontSize(18); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string content = Encoding.ASCII.GetString(bytes); + int redText = content.IndexOf("<43656C6C526564>", StringComparison.Ordinal); + int boldText = content.IndexOf("<43656C6C426F6C64>", StringComparison.Ordinal); + int markedText = content.IndexOf("<43656C6C4D61726B6564>", StringComparison.Ordinal); + int largeText = content.IndexOf("<43656C6C4C61726765>", StringComparison.Ordinal); + + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string pageText = string.Concat(pdf.GetPages().Select(page => page.Text)); + + Assert.Equal(1, CountOccurrences(pageText, "CellPlain")); + Assert.Equal(1, CountOccurrences(pageText, "CellRed")); + Assert.Equal(1, CountOccurrences(pageText, "CellBold")); + Assert.Equal(1, CountOccurrences(pageText, "CellMarked")); + Assert.Equal(1, CountOccurrences(pageText, "CellLarge")); + } + + Assert.True(redText >= 0, "Expected encoded 'CellRed' text in the native table PDF content stream."); + Assert.True(boldText > redText, "Expected encoded 'CellBold' text after the colored table cell run."); + Assert.True(markedText > boldText, "Expected encoded 'CellMarked' text after the bold table cell run."); + Assert.True(largeText > markedText, "Expected encoded 'CellLarge' text after the highlighted table cell run."); + Assert.True(content.LastIndexOf("1 0 0 rg", redText, StringComparison.Ordinal) >= 0, "Expected Word table cell run color to emit a red PDF fill color."); + Assert.True(content.LastIndexOf("/F2 11 Tf", boldText, StringComparison.Ordinal) >= 0, "Expected Word table cell bold run to use the bold PDF font resource."); + Assert.True(content.LastIndexOf("1 1 0 rg", markedText, StringComparison.Ordinal) >= 0, "Expected Word table cell run highlight to emit a yellow PDF fill color."); + Assert.True(content.LastIndexOf(" 18 Tf", largeText, StringComparison.Ordinal) >= 0, "Expected Word table cell run font size to emit an 18-point PDF run."); + } + + private static int CountOccurrences(string value, string search) { + int count = 0; + int index = 0; + while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0) { + count++; + index += search.Length; + } + + return count; + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Paragraph_Pagination_And_Tab_Style() { + using WordDocument document = WordDocument.Create(Path.Combine(_directoryWithFiles, "PdfNativeParagraphStyle.docx")); + WordParagraph paragraph = document.AddParagraph("Native style flags"); + paragraph.KeepLinesTogether = true; + paragraph.KeepWithNext = true; + paragraph.AvoidWidowAndOrphan = true; + paragraph.AddTabStop(1440, TabStopValues.Right, TabStopLeaderCharValues.Dot); + + MethodInfo method = typeof(WordPdfConverterExtensions).GetMethod("CreateNativeParagraphStyle", BindingFlags.NonPublic | BindingFlags.Static)!; + PdfParagraphStyle style = Assert.IsType(method.Invoke(null, new object[] { paragraph })); + + Assert.Equal(72D, style.DefaultTabStopWidth); + Assert.Equal(1.15D, style.LineHeight); + Assert.Equal(8D, style.SpacingAfter); + Assert.True(style.KeepTogether); + Assert.True(style.KeepWithNext); + Assert.True(style.WidowControl); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Explicit_Paragraph_Line_Spacing() { + using WordDocument document = WordDocument.Create(Path.Combine(_directoryWithFiles, "PdfNativeExplicitParagraphStyle.docx")); + WordParagraph exactParagraph = document.AddParagraph("Native exact line spacing"); + exactParagraph.FontSize = 12; + exactParagraph.LineSpacingPoints = 24; + + WordParagraph autoParagraph = document.AddParagraph("Native auto line spacing"); + autoParagraph.LineSpacing = 276; + autoParagraph.LineSpacingRule = LineSpacingRuleValues.Auto; + + MethodInfo method = typeof(WordPdfConverterExtensions).GetMethod("CreateNativeParagraphStyle", BindingFlags.NonPublic | BindingFlags.Static)!; + PdfParagraphStyle exactStyle = Assert.IsType(method.Invoke(null, new object[] { exactParagraph })); + PdfParagraphStyle autoStyle = Assert.IsType(method.Invoke(null, new object[] { autoParagraph })); + + Assert.Equal(2D, exactStyle.LineHeight); + Assert.Equal(1.15D, autoStyle.LineHeight); + } } -} \ No newline at end of file +} diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs index c7ca1f91e..dd31b60c1 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs @@ -1,8 +1,13 @@ using DocumentFormat.OpenXml.Wordprocessing; using OfficeIMO.Word; using OfficeIMO.Word.Pdf; +using System; using System.IO; +using System.Linq; +using System.Text; +using UglyToad.PdfPig; using Xunit; +using PdfCore = OfficeIMO.Pdf; namespace OfficeIMO.Tests; @@ -57,4 +62,1073 @@ public void Test_WordDocument_SaveAsPdf_HeaderFooterVariants() { Assert.True(File.Exists(pdfPath)); } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Default_Header_And_Footer_Text() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooter.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooter.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddParagraph("Native Default Header"); + RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph("Native Default Footer"); + document.AddParagraph("Native body text"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Native Default Header", allText); + Assert.Contains("Native Default Footer", allText); + Assert.Contains("Native body text", allText); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_Paragraph_Alignment_To_Zones() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeAlignedHeaderFooter.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeAlignedHeaderFooter.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordParagraph centeredHeader = RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddParagraph("NativeCenterHeader"); + centeredHeader.ParagraphAlignment = JustificationValues.Center; + WordParagraph rightFooter = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph("NativeRightFooter"); + rightFooter.ParagraphAlignment = JustificationValues.Right; + document.AddParagraph("NativeAlignedBody"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(400, 300), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(50) + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + var page = pdf.GetPage(1); + string allText = page.Text; + Assert.Contains("NativeCenterHeader", allText); + Assert.Contains("NativeRightFooter", allText); + Assert.Contains("NativeAlignedBody", allText); + + double bodyX = FindWordStartX(page, "NativeAlignedBody"); + double headerX = FindWordStartX(page, "NativeCenterHeader"); + double footerX = FindWordStartX(page, "NativeRightFooter"); + + Assert.InRange(bodyX, 49D, 61D); + Assert.True(headerX > bodyX + 45D, $"Expected centered header to render away from the left margin. Header x: {headerX:0.##}, body x: {bodyX:0.##}."); + Assert.True(footerX > headerX + 55D, $"Expected right footer to render to the right of centered header. Footer x: {footerX:0.##}, header x: {headerX:0.##}."); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_Table_Cells_To_Zones() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableHeaderFooter.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableHeaderFooter.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordTable headerTable = RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddTable(1, 3, WordTableStyle.TableNormal); + headerTable.Rows[0].Cells[0].Paragraphs[0].Text = "LHdr"; + headerTable.Rows[0].Cells[1].Paragraphs[0].Text = "CHdr"; + headerTable.Rows[0].Cells[2].Paragraphs[0].Text = "RHdr"; + + WordTable footerTable = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddTable(1, 2, WordTableStyle.TableNormal); + footerTable.Rows[0].Cells[0].Paragraphs[0].Text = "LFtr"; + footerTable.Rows[0].Cells[1].Paragraphs[0].Text = "RFtr"; + + document.AddParagraph("NativeTableZoneBody"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(520, 320), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(60) + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + var page = pdf.GetPage(1); + string allText = page.Text; + Assert.Contains("LHdr", allText); + Assert.Contains("CHdr", allText); + Assert.Contains("RHdr", allText); + Assert.Contains("LFtr", allText); + Assert.Contains("RFtr", allText); + Assert.Contains("NativeTableZoneBody", allText); + + double bodyX = FindWordStartX(page, "NativeTableZoneBody"); + double leftHeaderX = FindWordStartX(page, "LHdr"); + double centerHeaderX = FindWordStartX(page, "CHdr"); + double rightHeaderX = FindWordStartX(page, "RHdr"); + double leftFooterX = FindWordStartX(page, "LFtr"); + double rightFooterX = FindWordStartX(page, "RFtr"); + + Assert.InRange(bodyX, 58D, 72D); + Assert.InRange(leftHeaderX, 58D, 72D); + Assert.InRange(leftFooterX, 58D, 72D); + Assert.True(centerHeaderX > leftHeaderX + 75D, $"Expected center header table cell to render away from the left zone. Center x: {centerHeaderX:0.##}, left x: {leftHeaderX:0.##}."); + Assert.True(rightHeaderX > centerHeaderX + 75D, $"Expected right header table cell to render to the right of the center zone. Right x: {rightHeaderX:0.##}, center x: {centerHeaderX:0.##}."); + Assert.True(rightFooterX > leftFooterX + 150D, $"Expected two-cell footer table to map the last cell to the right zone. Right x: {rightFooterX:0.##}, left x: {leftFooterX:0.##}."); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Uses_OfficeIMO_Page_Setup_Options() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeOfficeIMOPageSetup.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeOfficeIMOPageSetup.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native page setup marker"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(240, 320), + OfficeIMOMargins = new PdfCore.PageMargins(80, 36, 36, 36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfCore.PdfPageInfo pageInfo = Assert.Single(PdfCore.PdfInspector.Inspect(bytes).Pages); + Assert.Equal(240, pageInfo.Width, 1); + Assert.Equal(320, pageInfo.Height, 1); + + using PdfDocument pdf = PdfDocument.Open(bytes); + var firstLetter = pdf.GetPage(1).Letters.First(letter => letter.Value == "N"); + Assert.InRange(firstLetter.StartBaseLine.X, 78, 92); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Preserves_Explicit_OfficeIMO_Page_Size_Geometry() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeExplicitPageGeometry.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeExplicitPageGeometry.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native explicit geometry marker"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(420, 240), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfCore.PdfPageInfo pageInfo = Assert.Single(PdfCore.PdfInspector.Inspect(bytes).Pages); + Assert.Equal(420, pageInfo.Width, 1); + Assert.Equal(240, pageInfo.Height, 1); + + using PdfDocument pdf = PdfDocument.Open(bytes); + var firstLetter = pdf.GetPage(1).Letters.First(letter => letter.Value == "N"); + Assert.InRange(firstLetter.StartBaseLine.X, 35D, 48D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Uses_Word_Section_Page_Setup_And_Margins() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeWordSectionPageSetup.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeWordSectionPageSetup.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordSection firstSection = document.Sections[0]; + firstSection.PageSettings.PageSize = WordPageSize.Letter; + firstSection.PageOrientation = PageOrientationValues.Portrait; + firstSection.SetMargins(WordMargin.Narrow); + document.AddParagraph("NarrowMarginMarker starts from the Word section margin."); + + WordSection secondSection = document.AddSection(); + secondSection.PageSettings.PageSize = WordPageSize.Letter; + secondSection.PageOrientation = PageOrientationValues.Landscape; + secondSection.SetMargins(WordMargin.Wide); + secondSection.AddParagraph("WideMarginMarker starts from the wider Word section margin."); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + Assert.Equal(2, info.PageCount); + Assert.Equal(612, info.Pages[0].Width, 1); + Assert.Equal(792, info.Pages[0].Height, 1); + Assert.Equal(792, info.Pages[1].Width, 1); + Assert.Equal(612, info.Pages[1].Height, 1); + + using PdfDocument pdf = PdfDocument.Open(bytes); + var firstPage = pdf.GetPage(1); + var secondPage = pdf.GetPage(2); + Assert.Contains("NarrowMarginMarker", firstPage.Text); + Assert.Contains("WideMarginMarker", secondPage.Text); + + double narrowX = FindWordStartX(firstPage, "NarrowMarginMarker"); + double wideX = FindWordStartX(secondPage, "WideMarginMarker"); + Assert.InRange(narrowX, 35D, 48D); + Assert.InRange(wideX, 140D, 156D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Word_Section_Columns_To_RowColumn_Flow() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionColumns.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionColumns.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordSection section = document.Sections[0]; + section.ColumnCount = 2; + section.ColumnsSpace = 720; + + document.AddParagraph("LeftColumnMarker starts in the first Word section column.") + .AddBreak(BreakValues.Column); + document.AddParagraph("RightColumnMarker starts in the second Word section column."); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(612, 792), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var page = pdf.GetPage(1); + string text = page.Text; + Assert.Contains("LeftColumnMarker", text); + Assert.Contains("RightColumnMarker", text); + + double leftX = FindWordStartX(page, "LeftColumnMarker"); + double rightX = FindWordStartX(page, "RightColumnMarker"); + Assert.InRange(leftX, 35D, 48D); + Assert.True(rightX > leftX + 250D, $"Expected the second Word section column to render to the right of the first. Left x: {leftX:0.##}, right x: {rightX:0.##}."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Unequal_Word_Section_Column_Widths() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeUnequalSectionColumns.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeUnequalSectionColumns.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordSection section = document.Sections[0]; + section.ColumnCount = 2; + section.ColumnsSpace = 720; + Columns columns = section._sectionProperties.GetFirstChild()!; + columns.EqualWidth = false; + columns.RemoveAllChildren(); + columns.Append( + new Column { Width = "1440", Space = "720" }, + new Column { Width = "4320" }); + + document.AddParagraph("NarrowColumnMarker starts in the explicitly narrow first Word section column.") + .AddBreak(BreakValues.Column); + document.AddParagraph("WideColumnMarker starts in the wider second Word section column."); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(612, 792), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var page = pdf.GetPage(1); + Assert.Contains("NarrowColumnMarker", page.Text); + Assert.Contains("WideColumnMarker", page.Text); + + double leftX = FindWordStartX(page, "NarrowColumnMarker"); + double rightX = FindWordStartX(page, "WideColumnMarker"); + + Assert.InRange(leftX, 35D, 48D); + Assert.InRange(rightX - leftX, 145D, 190D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Word_Section_Column_Separator() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionColumnSeparator.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionColumnSeparator.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordSection section = document.Sections[0]; + section.ColumnCount = 2; + section.ColumnsSpace = 720; + section.HasColumnSeparator = true; + + document.AddParagraph("SeparatorLeftMarker starts in the first Word section column.") + .AddBreak(BreakValues.Column); + document.AddParagraph("SeparatorRightMarker starts in the second Word section column."); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(612, 792), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + string rawPdf = Encoding.ASCII.GetString(bytes); + using PdfDocument pdf = PdfDocument.Open(bytes); + var page = pdf.GetPage(1); + + Assert.Contains("SeparatorLeftMarker", page.Text); + Assert.Contains("SeparatorRightMarker", page.Text); + Assert.Contains("0.5 w", rawPdf, StringComparison.Ordinal); + Assert.Contains("306 756 m 306 ", rawPdf, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Distributes_Word_Section_Columns_Without_Explicit_Breaks() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeAutomaticSectionColumns.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeAutomaticSectionColumns.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordSection section = document.Sections[0]; + section.ColumnCount = 2; + section.ColumnsSpace = 720; + + document.AddParagraph("AutoLeftColumnMarker starts in the first automatic Word section column."); + document.AddParagraph("AutoRightColumnMarker starts in the second automatic Word section column."); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(612, 792), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var page = pdf.GetPage(1); + string text = page.Text; + Assert.Contains("AutoLeftColumnMarker", text); + Assert.Contains("AutoRightColumnMarker", text); + + double leftX = FindWordStartX(page, "AutoLeftColumnMarker"); + double rightX = FindWordStartX(page, "AutoRightColumnMarker"); + Assert.InRange(leftX, 35D, 48D); + Assert.True(rightX > leftX + 250D, $"Expected automatic second Word section column content to render to the right of the first. Left x: {leftX:0.##}, right x: {rightX:0.##}."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Keeps_Automatic_Column_Headings_With_Following_Content() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeAutomaticSectionColumnHeadingKeep.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeAutomaticSectionColumnHeadingKeep.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordSection section = document.Sections[0]; + section.ColumnCount = 2; + section.ColumnsSpace = 720; + + document.AddParagraph("ColumnKeepPrelude " + string.Join(" ", Enumerable.Range(1, 42).Select(index => "prelude" + index.ToString(System.Globalization.CultureInfo.InvariantCulture)))); + document.AddParagraph("ColumnKeepHeading").SetStyle(WordParagraphStyles.Heading2); + document.AddParagraph("ColumnKeepBody follows the heading and should stay in the same automatic Word section column."); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(612, 792), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var page = pdf.GetPage(1); + + double preludeX = FindWordStartX(page, "ColumnKeepPrelude"); + double headingX = FindWordStartX(page, "ColumnKeepHeading"); + double bodyX = FindWordStartX(page, "ColumnKeepBody"); + + Assert.InRange(preludeX, 35D, 48D); + Assert.True(headingX > preludeX + 250D, $"Expected the kept heading to move into the second automatic column. Prelude x: {preludeX:0.##}, heading x: {headingX:0.##}."); + Assert.InRange(Math.Abs(bodyX - headingX), 0D, 8D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Splits_Inline_Word_Column_Breaks() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeInlineSectionColumnBreak.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeInlineSectionColumnBreak.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordSection section = document.Sections[0]; + section.ColumnCount = 2; + section.ColumnsSpace = 720; + + WordParagraph paragraph = document.AddParagraph(); + paragraph.AddText("InlineLeftColumnMarker remains before the inline Word column break."); + paragraph.AddBreak(BreakValues.Column); + paragraph.AddText("InlineRightColumnMarker starts after the inline Word column break."); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(612, 792), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var page = pdf.GetPage(1); + string text = page.Text; + Assert.Contains("InlineLeftColumnMarker", text); + Assert.Contains("InlineRightColumnMarker", text); + + double leftX = FindWordStartX(page, "InlineLeftColumnMarker"); + double rightX = FindWordStartX(page, "InlineRightColumnMarker"); + Assert.InRange(leftX, 35D, 48D); + Assert.True(rightX > leftX + 250D, $"Expected text after an inline Word column break to render in the next section column. Left x: {leftX:0.##}, right x: {rightX:0.##}."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_First_And_Even_HeaderFooter_Variants() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterVariants.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterVariants.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + document.DifferentFirstPage = true; + document.DifferentOddAndEvenPages = true; + + RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddParagraph("Native Odd Header"); + RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph("Native Odd Footer"); + RequireSectionHeader(document, 0, HeaderFooterValues.First).AddParagraph("Native First Header"); + RequireSectionFooter(document, 0, HeaderFooterValues.First).AddParagraph("Native First Footer"); + RequireSectionHeader(document, 0, HeaderFooterValues.Even).AddParagraph("Native Even Header"); + RequireSectionFooter(document, 0, HeaderFooterValues.Even).AddParagraph("Native Even Footer"); + + for (int i = 0; i < 240; i++) { + document.AddParagraph($"Native variant paragraph {i}"); + } + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + Assert.True(pdf.NumberOfPages >= 3); + string firstPageText = pdf.GetPage(1).Text; + string secondPageText = pdf.GetPage(2).Text; + string thirdPageText = pdf.GetPage(3).Text; + + Assert.Contains("Native First Header", firstPageText); + Assert.Contains("Native First Footer", firstPageText); + Assert.Contains("Native Even Header", secondPageText); + Assert.Contains("Native Even Footer", secondPageText); + Assert.Contains("Native Odd Header", thirdPageText); + Assert.Contains("Native Odd Footer", thirdPageText); + } + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Records_Warnings_For_Unsupported_HeaderFooter_Content() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterWarnings.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterWarnings.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + options.Warnings.Add(new PdfExportWarning("Stale", "test", "This should be cleared before export.")); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordHeader header = RequireSectionHeader(document, 0, HeaderFooterValues.Default); + header.AddParagraph("Native warning header text"); + header.AddParagraph().AddTextBox(string.Empty, WrapTextImage.Square); + + WordTable footerTable = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddTable(1, 1, WordTableStyle.TableNormal); + footerTable.Rows[0].Cells[0].Paragraphs[0].AddTextBox(string.Empty, WrapTextImage.Square); + + document.AddParagraph("Native warning body text"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "Stale"); + Assert.Contains(options.Warnings, warning => + warning.Code == "NativeHeaderFooterTextBoxUnsupported" && + warning.Source == "default header"); + Assert.Contains(options.Warnings, warning => + warning.Code == "NativeHeaderFooterTextBoxUnsupported" && + warning.Source == "default footer table"); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native warning header text", text); + Assert.Contains("Native warning body text", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_TextBoxes() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterTextBoxes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterTextBoxes.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordTextBox headerTextBox = RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddTextBox("Native header text box"); + headerTextBox.HorizontalAlignment = WordHorizontalAlignmentValues.Center; + + WordParagraph footerParagraph = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph(); + WordTextBox footerTextBox = footerParagraph.AddTextBox("Native footer text box", WrapTextImage.Square); + footerTextBox.HorizontalAlignment = WordHorizontalAlignmentValues.Right; + + document.AddParagraph("Native text box body"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeHeaderFooterTextBoxUnsupported"); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native header text box", text); + Assert.Contains("Native footer text box", text); + Assert.Contains("Native text box body", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_Shapes() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterShapes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterShapes.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordHeader header = RequireSectionHeader(document, 0, HeaderFooterValues.Default); + header.AddShape(ShapeType.Rectangle, 36, 16, "#99ccff", "#003366", 1.5); + + WordFooter footer = RequireSectionFooter(document, 0, HeaderFooterValues.Default); + WordParagraph footerParagraph = footer.AddParagraph(); + footerParagraph.ParagraphAlignment = JustificationValues.Right; + footerParagraph.AddShape(ShapeType.Rectangle, 34, 14, "#ffe699", "#663300", 1.25); + + document.AddParagraph("Native header footer shape body"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeHeaderFooterShapeUnsupported"); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native header footer shape body", text); + + string content = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains("0.6 0.8 1 rg", content); + Assert.Contains("1 0.902 0.6 rg", content); + Assert.Contains("0 0.2 0.4 RG", content); + Assert.Contains("0.4 0.2 0 RG", content); + Assert.Contains(" re B", content); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_Images() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterImages.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterImages.pdf"); + string imagePath = Path.Combine(_directoryWithImages, "EvotecLogo.png"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordParagraph headerParagraph = RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddParagraph("Native image header"); + headerParagraph.ParagraphAlignment = JustificationValues.Center; + headerParagraph.AddImage(imagePath, 32, 32); + + WordTable footerTable = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddTable(1, 3, WordTableStyle.TableNormal); + footerTable.Rows[0].Cells[2].Paragraphs[0].AddImage(imagePath, 32, 32); + + document.AddParagraph("Native header/footer image body"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeHeaderFooterImageUnsupported"); + + byte[] bytes = File.ReadAllBytes(pdfPath); + string rawPdf = Encoding.ASCII.GetString(bytes); + int imageObjectCount = rawPdf.Split(new[] { "/Subtype /Image" }, StringSplitOptions.None).Length - 1; + Assert.True(imageObjectCount >= 2, "Expected native header and footer images to be emitted as image XObjects."); + + using PdfDocument pdf = PdfDocument.Open(bytes); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native image header", text); + Assert.Contains("Native header/footer image body", text); + Assert.DoesNotContain("Page 1", text, StringComparison.Ordinal); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_PictureControls_To_Images() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterPictureControls.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterPictureControls.pdf"); + string imagePath = Path.Combine(_directoryWithImages, "EvotecLogo.png"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordParagraph headerParagraph = RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddParagraph("Native picture-control header"); + headerParagraph.ParagraphAlignment = JustificationValues.Center; + headerParagraph.AddPictureControl(imagePath, 32, 32, "Header Logo", "HeaderLogo"); + + WordTable footerTable = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddTable(1, 3, WordTableStyle.TableNormal); + footerTable.Rows[0].Cells[2].Paragraphs[0].AddPictureControl(imagePath, 32, 32, "Footer Logo", "FooterLogo"); + + document.AddParagraph("Native header/footer picture-control body"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeHeaderFooterContentControlUnsupported"); + + byte[] bytes = File.ReadAllBytes(pdfPath); + string rawPdf = Encoding.ASCII.GetString(bytes); + int imageObjectCount = rawPdf.Split(new[] { "/Subtype /Image" }, StringSplitOptions.None).Length - 1; + Assert.True(imageObjectCount >= 2, "Expected native header and footer picture controls to be emitted as image XObjects."); + + using PdfDocument pdf = PdfDocument.Open(bytes); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native picture-control header", text); + Assert.Contains("Native header/footer picture-control body", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_RepeatingSections_To_Text_Items() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterRepeatingSections.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterRepeatingSections.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordRepeatingSection headerRepeating = RequireSectionHeader(document, 0, HeaderFooterValues.Default) + .AddParagraph("Native header repeating section: ") + .AddRepeatingSection("HeaderTasks", "HeaderTasks", "HeaderTasksTag"); + headerRepeating.SetTextItems(new[] { "Header item one", "Header item two" }); + + WordTable footerTable = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddTable(1, 3, WordTableStyle.TableNormal); + WordRepeatingSection footerRepeating = footerTable.Rows[0].Cells[2].Paragraphs[0] + .AddRepeatingSection("FooterTasks", "FooterTasks", "FooterTasksTag"); + footerRepeating.SetTextItems(new[] { "Footer item one", "Footer item two" }); + + document.AddParagraph("Native header/footer repeating-section body"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeHeaderFooterContentControlUnsupported"); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native header repeating section:", text); + Assert.Contains("Header item one", text); + Assert.Contains("Header item two", text); + Assert.Contains("Footer item one", text); + Assert.Contains("Footer item two", text); + Assert.Contains("Native header/footer repeating-section body", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_FormControls_To_Static_Text() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterFormControls.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterFormControls.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordHeader header = RequireSectionHeader(document, 0, HeaderFooterValues.Default); + header.AddParagraph("Native header approval: ").AddCheckBox(true, "Header Approval", "HeaderApproval"); + header.AddParagraph("Native header due: ").AddDatePicker(new DateTime(2026, 5, 31), "Header Due", "HeaderDue"); + + WordTable footerTable = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddTable(1, 3, WordTableStyle.TableNormal); + footerTable.Rows[0].Cells[0].Paragraphs[0].Text = "Native footer region: "; + footerTable.Rows[0].Cells[0].Paragraphs[0].AddDropDownList(new[] { "North", "South" }, "Footer Region", "FooterRegion"); + footerTable.Rows[0].Cells[2].Paragraphs[0].Text = "Native footer status: "; + footerTable.Rows[0].Cells[2].Paragraphs[0].AddComboBox(new[] { "Red", "Blue" }, "Footer Status", "FooterStatus", defaultValue: "Blue"); + + document.AddParagraph("Native header/footer form-control body"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeHeaderFooterContentControlUnsupported"); + + byte[] bytes = File.ReadAllBytes(pdfPath); + Assert.Empty(PdfCore.PdfInspector.Inspect(bytes).FormFields); + + using PdfDocument pdf = PdfDocument.Open(bytes); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native header approval:", text); + Assert.Contains("[x]", text); + Assert.Contains("Native header due:", text); + Assert.Contains("2026-05-31", text); + Assert.Contains("Native footer region:", text); + Assert.Contains("North", text); + Assert.Contains("Native footer status:", text); + Assert.Contains("Blue", text); + Assert.Contains("Native header/footer form-control body", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Simple_Equations_To_Static_Text() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeSimpleEquations.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeSimpleEquations.pdf"); + const string headerOmml = "h=2"; + const string bodyOmml = "b=3"; + const string tableOmml = "c=4"; + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + RequireSectionHeader(document, 0, HeaderFooterValues.Default) + .AddParagraph("Native header equation:") + .AddEquation(headerOmml); + + document.AddParagraph("Native body equation:").AddEquation(bodyOmml); + + WordTable table = document.AddTable(1, 1, WordTableStyle.TableNormal); + table.Rows[0].Cells[0].Paragraphs[0].Text = "Native table equation:"; + table.Rows[0].Cells[0].Paragraphs[0].AddEquation(tableOmml); + + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeHeaderFooterEquationUnsupported"); + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeBodyEquationUnsupported"); + + string text = PdfCore.PdfTextExtractor.ExtractAllText(pdfPath); + Assert.Contains("Native header equation:", text); + Assert.Contains("h=2", text); + Assert.Contains("Native body equation:", text); + Assert.Contains("b=3", text); + string normalizedText = NormalizePdfText(text); + Assert.Contains("Native table equation:", normalizedText); + Assert.Contains("c=4", normalizedText); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Records_Warnings_For_Unsupported_Body_Content() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeBodyWarnings.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeBodyWarnings.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + const string omml = "x=1"; + document.AddParagraph("Native body control text").AddDropDownList(new[] { "One", "Two" }, "BodyControl", "BodyControlTag"); + + WordTable table = document.AddTable(1, 1, WordTableStyle.TableNormal); + table.Rows[0].Cells[0].Paragraphs[0].Text = "NativeTableControlText"; + table.Rows[0].Cells[0].Paragraphs[0].AddEquation(omml); + + document.AddEmbeddedFragment("

Embedded body fragment

", WordAlternativeFormatImportPartType.Html); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyContentControlUnsupported" && + warning.Source == "body paragraph"); + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyEquationUnsupported" && + warning.Source == "body table"); + Assert.Contains(options.Warnings, warning => + warning.Code == "NativeBodyEmbeddedDocumentUnsupported" && + warning.Source == "body"); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native body control text", text); + Assert.Contains("NativeTableControlText", text); + Assert.Contains("x=1", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Simple_Text_ContentControls() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeSimpleTextContentControls.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeSimpleTextContentControls.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + RequireSectionHeader(document, 0, HeaderFooterValues.Default) + .AddParagraph("Native header content control: ") + .AddStructuredDocumentTag("Header control", "HeaderAlias", "HeaderTag"); + + document.AddParagraph("Native body content control: ") + .AddStructuredDocumentTag("Body control", "BodyAlias", "BodyTag"); + + WordTable table = document.AddTable(1, 1, WordTableStyle.TableNormal); + table.Rows[0].Cells[0].Paragraphs[0].Text = "Native cell content control: "; + table.Rows[0].Cells[0].Paragraphs[0].AddStructuredDocumentTag("Cell control", "CellAlias", "CellTag"); + + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeBodyContentControlUnsupported"); + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeHeaderFooterContentControlUnsupported"); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native header content control:", text); + Assert.Contains("Header control", text); + Assert.Contains("Native body content control:", text); + Assert.Contains("Body control", text); + Assert.Contains("Native cell content", text); + Assert.Contains("Cell control", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Body_DropDown_ComboBox_And_DatePicker_To_AcroForm_Fields() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeBodyContentControlFormFields.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeBodyContentControlFormFields.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native dropdown: ").AddDropDownList(new[] { "Poland", "Germany" }, "Country", "CountryTag"); + document.AddParagraph("Native combo: ").AddComboBox(new[] { "Red", "Blue" }, "Color", "ColorTag", defaultValue: "Blue"); + document.AddParagraph("Native date: ").AddDatePicker(new DateTime(2026, 5, 29), "Due Date", "DueDateTag"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyContentControlUnsupported" && + warning.Source == "body paragraph"); + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + Assert.Equal(3, info.FormFields.Count); + + PdfCore.PdfFormField country = Assert.Single(info.FormFields, field => field.Name == "CountryTag"); + Assert.Equal(PdfCore.PdfFormFieldKind.Choice, country.Kind); + Assert.True(country.IsCombo); + Assert.Equal("Poland", country.Value); + Assert.Equal(new[] { "Poland", "Germany" }, country.Options.Select(option => option.ExportValue).ToArray()); + + PdfCore.PdfFormField color = Assert.Single(info.FormFields, field => field.Name == "ColorTag"); + Assert.Equal(PdfCore.PdfFormFieldKind.Choice, color.Kind); + Assert.True(color.IsCombo); + Assert.Equal("Blue", color.Value); + + PdfCore.PdfFormField dueDate = Assert.Single(info.FormFields, field => field.Name == "DueDateTag"); + Assert.Equal(PdfCore.PdfFormFieldKind.Text, dueDate.Kind); + Assert.Equal("2026-05-29", dueDate.Value); + + using PdfDocument pdf = PdfDocument.Open(bytes); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native dropdown:", text); + Assert.Contains("Native combo:", text); + Assert.Contains("Native date:", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Cell_DropDown_ComboBox_And_DatePicker_To_AcroForm_Fields() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellContentControlFormFields.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellContentControlFormFields.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1, WordTableStyle.TableGrid); + table.WidthType = TableWidthUnitValues.Dxa; + table.Width = 7200; + table.ColumnWidth = new[] { 7200 }.ToList(); + table.ColumnWidthType = TableWidthUnitValues.Dxa; + WordParagraph paragraph = table.Rows[0].Cells[0].Paragraphs[0]; + paragraph.Text = "Native table controls:"; + paragraph.AddDropDownList(new[] { "Poland", "Germany" }, "Cell Country", "CellCountry"); + paragraph.AddComboBox(new[] { "Red", "Blue" }, "Cell Color", "CellColor", defaultValue: "Blue"); + paragraph.AddDatePicker(new DateTime(2026, 5, 31), "Cell Due Date", "CellDueDate"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyContentControlUnsupported" && + warning.Source == "body table"); + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + Assert.Equal(3, info.FormFields.Count); + Assert.Contains(info.FormFields, field => field.Name == "CellCountry" && field.IsChoiceField && field.Value == "Poland"); + Assert.Contains(info.FormFields, field => field.Name == "CellColor" && field.IsChoiceField && field.Value == "Blue"); + Assert.Contains(info.FormFields, field => field.Name == "CellDueDate" && field.IsTextField && field.Value == "2026-05-31"); + Assert.True(info.Pages[0].HasFormWidgets); + + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.Contains("Native table controls:", pdf.GetPage(1).Text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Body_RepeatingSection_To_Text_Items() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeBodyRepeatingSection.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeBodyRepeatingSection.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Native repeating section:"); + WordRepeatingSection repeatingSection = document.AddParagraph() + .AddRepeatingSection("Tasks", "Tasks", "TasksTag"); + repeatingSection.SetTextItems(new[] { "Plan roadmap slice", "Validate native PDF output" }); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyContentControlUnsupported" && + warning.Source == "body paragraph"); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Native repeating section:", text); + Assert.Contains("Plan roadmap slice", text); + Assert.Contains("Validate native PDF output", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Cell_RepeatingSection_To_Text_Items() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellRepeatingSection.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellRepeatingSection.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1, WordTableStyle.TableGrid); + table.WidthType = TableWidthUnitValues.Dxa; + table.Width = 7200; + table.ColumnWidth = new[] { 7200 }.ToList(); + table.ColumnWidthType = TableWidthUnitValues.Dxa; + table.Rows[0].Cells[0].Paragraphs[0].Text = "Table tasks"; + WordRepeatingSection repeatingSection = table.Rows[0].Cells[0].Paragraphs[0] + .AddRepeatingSection("Tasks", "Tasks", "TasksTag"); + repeatingSection.SetTextItems(new[] { "Render cell item", "Keep table warnings clean" }); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyContentControlUnsupported" && + warning.Source == "body table"); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Table tasks", text); + Assert.Contains("Render cell item", text); + Assert.Contains("Keep table warnings clean", text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Body_CheckBox_To_AcroForm_Field() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeBodyCheckBox.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeBodyCheckBox.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Accept native checkbox").AddCheckBox(true, "Accept Native", "AcceptNative"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeBodyContentControlUnsupported"); + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + PdfCore.PdfFormField field = Assert.Single(info.FormFields); + Assert.Equal("AcceptNative", field.Name); + Assert.Equal(PdfCore.PdfFormFieldKind.Button, field.Kind); + Assert.True(field.IsCheckBox); + Assert.Equal("Yes", field.Value); + + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.Contains("Accept native checkbox", pdf.GetPage(1).Text); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Cell_CheckBox_To_AcroForm_Field() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellCheckBox.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellCheckBox.pdf"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1, WordTableStyle.TableGrid); + table.Rows[0].Cells[0].Paragraphs[0].Text = "Table cell approval"; + table.Rows[0].Cells[0].Paragraphs[0].AddCheckBox(true, "Table Cell Approval", "TableCellApproval"); + document.Save(); + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => + warning.Code == "NativeBodyContentControlUnsupported" && + warning.Source == "body table"); + + byte[] bytes = File.ReadAllBytes(pdfPath); + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + PdfCore.PdfFormField field = Assert.Single(info.FormFields); + Assert.Equal("TableCellApproval", field.Name); + Assert.Equal(PdfCore.PdfFormFieldKind.Button, field.Kind); + Assert.True(field.IsCheckBox); + Assert.Equal("Yes", field.Value); + Assert.True(info.Pages[0].HasFormWidgets); + + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.Contains("Table cell approval", pdf.GetPage(1).Text); + } + + private static double FindWordStartX(UglyToad.PdfPig.Content.Page page, string word) { + var lines = page.Letters + .Where(letter => !string.IsNullOrWhiteSpace(letter.Value)) + .GroupBy(letter => Math.Round(letter.StartBaseLine.Y, 1)); + + foreach (var line in lines) { + var ordered = line.OrderBy(letter => letter.StartBaseLine.X).ToList(); + string text = string.Concat(ordered.Select(letter => letter.Value)); + int index = text.IndexOf(word, StringComparison.Ordinal); + if (index >= 0) { + return ordered[index].StartBaseLine.X; + } + } + + throw new InvalidOperationException("Could not find word '" + word + "' in rendered PDF text."); + } + + private static string NormalizePdfText(string text) => + string.Join(" ", text.Split(new[] { ' ', '\r', '\n', '\t', '\f' }, StringSplitOptions.RemoveEmptyEntries)); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Shapes.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Shapes.cs index 970890db7..0af26bc46 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Shapes.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Shapes.cs @@ -1,47 +1,150 @@ using OfficeIMO.Word; using OfficeIMO.Word.Pdf; -using Color = OfficeIMO.Drawing.OfficeColor; using System.IO; -using System.IO.Compression; +using System.Linq; using System.Text; +using UglyToad.PdfPig; using Xunit; -namespace OfficeIMO.Tests; +namespace OfficeIMO.Tests { + public partial class Word { + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Simple_Shapes() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeShapes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeShapes.pdf"); -public partial class Word { - [Fact] - public void Test_WordDocument_SaveAsPdf_Shapes() { - string docPath = Path.Combine(_directoryWithFiles, "PdfShapesSample.docx"); - string pdfPath = Path.Combine(_directoryWithFiles, "PdfShapesSample.pdf"); + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before native shapes"); + document.AddShape(ShapeType.Rectangle, 100, 36, "#ccb399", "#1a334d", 2.5); + document.AddShape(ShapeType.Ellipse, 60, 30, "#d0e6ff", "#224466", 1.25); + document.AddShape(ShapeType.Line, 80, 0, "#ffffff", "#008000", 2); + document.AddParagraph("After native shapes"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } - using (WordDocument document = WordDocument.Create(docPath)) { - var paragraph = document.AddParagraph(); - paragraph.AddShape(ShapeType.Rectangle, 80, 40, Color.Aqua, Color.Black, 1); - WordShape.AddLine(paragraph, 0, 50, 80, 50, Color.Red, 2); - document.Save(); - document.SaveAsPdf(pdfPath); + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Before native shapes", allText); + Assert.Contains("After native shapes", allText); + } + + string content = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains("0.8 0.702 0.6 rg", content); + Assert.Contains("0.102 0.2 0.302 RG", content); + Assert.Contains("2.5 w", content); + Assert.Contains(" re B", content); + Assert.Contains("0 0.502 0 RG", content); + Assert.Contains("2 w", content); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_DrawingML_Preset_Shapes() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeDrawingPresetShapes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeDrawingPresetShapes.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before native DrawingML shapes"); + WordShape triangle = document.AddParagraph().AddShapeDrawing(ShapeType.Triangle, 72, 48); + triangle.FillColorHex = "#99ccff"; + triangle.StrokeColorHex = "#003366"; + triangle.StrokeWeight = 1.5; + Assert.Equal(1.5, triangle.StrokeWeight); + WordShape diamond = document.AddParagraph().AddShapeDrawing(ShapeType.Diamond, 64, 48); + diamond.FillColorHex = "#ffe699"; + WordShape arrow = document.AddParagraph().AddShapeDrawing(ShapeType.RightArrow, 96, 36); + arrow.FillColorHex = "#b7e1cd"; + WordShape star = document.AddParagraph().AddShapeDrawing(ShapeType.Star5, 56, 56); + star.FillColorHex = "#f4b183"; + document.AddParagraph("After native DrawingML shapes"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Before native DrawingML shapes", allText); + Assert.Contains("After native DrawingML shapes", allText); + } + + string content = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains("0.6 0.8 1 rg", content); + Assert.Contains("0 0.2 0.4 RG", content); + Assert.Contains("1.5 w", content); + Assert.Contains("1 0.902 0.6 rg", content); + Assert.Contains("0.718 0.882 0.804 rg", content); + Assert.Contains("0.957 0.694 0.514 rg", content); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Remaining_DrawingML_Preset_Shapes() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeRemainingDrawingPresetShapes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeRemainingDrawingPresetShapes.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before remaining native DrawingML shapes"); + document.AddParagraph().AddShapeDrawing(ShapeType.Heart, 64, 56).FillColorHex = "#ff0000"; + document.AddParagraph().AddShapeDrawing(ShapeType.Cloud, 88, 52).FillColorHex = "#00ff00"; + document.AddParagraph().AddShapeDrawing(ShapeType.Donut, 64, 64).FillColorHex = "#0000ff"; + document.AddParagraph().AddShapeDrawing(ShapeType.Can, 64, 64).FillColorHex = "#ffff00"; + document.AddParagraph().AddShapeDrawing(ShapeType.Cube, 72, 60).FillColorHex = "#00ffff"; + document.AddParagraph("After remaining native DrawingML shapes"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Before remaining native DrawingML shapes", allText); + Assert.Contains("After remaining native DrawingML shapes", allText); + } + + string content = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains("1 0 0 rg", content); + Assert.Contains("0 1 0 rg", content); + Assert.Contains("0 0 1 rg", content); + Assert.Contains("1 1 0 rg", content); + Assert.Contains("0 1 1 rg", content); + Assert.Contains(" h\n", content); } - Assert.True(File.Exists(pdfPath)); - - byte[] bytes = File.ReadAllBytes(pdfPath); - byte[] startPattern = Encoding.ASCII.GetBytes("stream\n"); - byte[] endPattern = Encoding.ASCII.GetBytes("\nendstream"); - int start = IndexOf(bytes, startPattern, 0); - Assert.True(start >= 0, "stream marker not found"); - start += startPattern.Length; - int end = IndexOf(bytes, endPattern, start); - Assert.True(end >= 0, "endstream marker not found"); - int length = end - start; - if (length > 6) { - int deflateLength = length - 6; - byte[] deflateData = new byte[deflateLength]; - System.Array.Copy(bytes, start + 2, deflateData, 0, deflateLength); - using MemoryStream ms = new MemoryStream(deflateData); - using DeflateStream ds = new DeflateStream(ms, CompressionMode.Decompress); - using StreamReader reader = new StreamReader(ds, Encoding.GetEncoding("ISO-8859-1")); - string content = reader.ReadToEnd(); - Assert.False(string.IsNullOrEmpty(content)); + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Simple_TextBoxes() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTextBoxes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTextBoxes.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before native text box"); + WordTextBox textBox = document.AddTextBox("Native text box body"); + textBox.WidthCentimeters = 7; + textBox.HorizontalAlignment = WordHorizontalAlignmentValues.Center; + textBox.Paragraphs[0].Bold = true; + document.AddParagraph("After native text box"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + Assert.True(File.Exists(pdfPath)); + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Before native text box", allText); + Assert.Contains("Native text box body", allText); + Assert.Contains("After native text box", allText); + } + + string content = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains(" re S", content); } } } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs index eafc9474f..d6cb05475 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs @@ -3,7 +3,9 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using UglyToad.PdfPig; using Xunit; +using PdfCore = OfficeIMO.Pdf; namespace OfficeIMO.Tests; @@ -59,4 +61,55 @@ public async Task Test_WordDocument_SaveAsPdfAsync_ToStream_Rewinds() { Assert.Equal(0, stream.Position); Assert.True(stream.Length > 0); } -} \ No newline at end of file + + [Fact] + public void Test_WordDocument_SaveAsPdf_OfficeIMOEngine_ToStream_Rewinds() { + var docPath = Path.Combine(_directoryWithFiles, "PdfNativeStreamRewind.docx"); + + using var document = WordDocument.Create(docPath); + document.AddParagraph("Hello native stream"); + document.Save(); + + using var stream = new MemoryStream(); + document.SaveAsPdf(stream, new PdfSaveOptions()); + Assert.Equal(0, stream.Position); + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Test_WordDocument_SaveAsPdfAsync_OfficeIMOEngine_ToStream_Rewinds() { + var docPath = Path.Combine(_directoryWithFiles, "PdfNativeStreamRewindAsync.docx"); + + using var document = WordDocument.Create(docPath); + document.AddParagraph("Hello native async stream"); + document.Save(); + + using var stream = new MemoryStream(); + await document.SaveAsPdfAsync(stream, new PdfSaveOptions(), CancellationToken.None); + Assert.Equal(0, stream.Position); + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Test_WordDocument_SaveAsPdfAsync_OfficeIMOEngine_ToBytes_UsesNativeEngine() { + var docPath = Path.Combine(_directoryWithFiles, "PdfNativeAsyncBytes.docx"); + + using var document = WordDocument.Create(docPath); + document.AddParagraph("Hello native async bytes"); + document.Save(); + + byte[] bytes = await document.SaveAsPdfAsync(new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(240, 320), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + }, CancellationToken.None); + + Assert.True(bytes.Length > 0); + PdfCore.PdfPageInfo pageInfo = Assert.Single(PdfCore.PdfInspector.Inspect(bytes).Pages); + Assert.Equal(240, pageInfo.Width, 1); + Assert.Equal(320, pageInfo.Height, 1); + + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.Contains("Hello native async bytes", pdf.GetPage(1).Text); + } +} diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs index a5a4bd74a..26791227b 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs @@ -1,10 +1,15 @@ using DocumentFormat.OpenXml.Wordprocessing; +using System; using OfficeIMO.Word.Pdf; using OfficeIMO.Word; using System.IO; using System.IO.Compression; +using System.Linq; +using System.Reflection; using System.Text; +using UglyToad.PdfPig; using Xunit; +using PdfCore = OfficeIMO.Pdf; namespace OfficeIMO.Tests; @@ -54,4 +59,867 @@ public void Test_WordDocument_SaveAsPdf_TableStyles() { Assert.Contains("1 0 0 rg", content); Assert.Contains("0 0 1 RG", content); } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Fill_And_Border() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeStyledTable.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeStyledTable.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1); + WordTableCell cell = table.Rows[0].Cells[0]; + cell.Paragraphs[0].Text = "Native styled cell"; + cell.ShadingFillColorHex = "ff0000"; + cell.Borders.TopStyle = BorderValues.Single; + cell.Borders.BottomStyle = BorderValues.Single; + cell.Borders.LeftStyle = BorderValues.Single; + cell.Borders.RightStyle = BorderValues.Single; + cell.Borders.TopColorHex = "0000ff"; + cell.Borders.BottomColorHex = "0000ff"; + cell.Borders.LeftColorHex = "0000ff"; + cell.Borders.RightColorHex = "0000ff"; + cell.Borders.TopSize = 8; + cell.Borders.BottomSize = 8; + cell.Borders.LeftSize = 8; + cell.Borders.RightSize = 8; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Native styled cell", text); + } + + string raw = Encoding.ASCII.GetString(bytes); + Assert.Contains("1 0 0 rg", raw); + Assert.Contains("0 0 1 RG", raw); + Assert.Contains("1 w", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_NonUniform_Borders() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellNonUniformBorders.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellNonUniformBorders.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1); + WordTableCell cell = table.Rows[0].Cells[0]; + cell.Paragraphs[0].Text = "Native non-uniform cell"; + cell.Borders.TopStyle = BorderValues.Single; + cell.Borders.TopColorHex = "ff0000"; + cell.Borders.TopSize = 16; + cell.Borders.RightStyle = BorderValues.Single; + cell.Borders.RightColorHex = "0000ff"; + cell.Borders.RightSize = 20; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Native non-uniform", text); + Assert.Contains("cell", text); + } + + string raw = Encoding.ASCII.GetString(bytes); + Assert.Contains("1 0 0 RG", raw); + Assert.Contains("2 w", raw); + Assert.Contains("0 0 1 RG", raw); + Assert.Contains("2.5 w", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Double_And_Diagonal_Borders() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellDoubleDiagonalBorders.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellDoubleDiagonalBorders.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1); + WordTableCell cell = table.Rows[0].Cells[0]; + cell.Paragraphs[0].Text = "Native double diagonal cell"; + cell.Borders.TopStyle = BorderValues.Double; + cell.Borders.BottomStyle = BorderValues.Double; + cell.Borders.LeftStyle = BorderValues.Double; + cell.Borders.RightStyle = BorderValues.Double; + cell.Borders.TopColorHex = "123456"; + cell.Borders.BottomColorHex = "123456"; + cell.Borders.LeftColorHex = "123456"; + cell.Borders.RightColorHex = "123456"; + cell.Borders.TopSize = 8; + cell.Borders.BottomSize = 8; + cell.Borders.LeftSize = 8; + cell.Borders.RightSize = 8; + cell.Borders.TopLeftToBottomRightStyle = BorderValues.Double; + cell.Borders.TopLeftToBottomRightColorHex = "654321"; + cell.Borders.TopLeftToBottomRightSize = 8; + cell.Borders.TopRightToBottomLeftStyle = BorderValues.Double; + cell.Borders.TopRightToBottomLeftColorHex = "654321"; + cell.Borders.TopRightToBottomLeftSize = 8; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), + OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Native double diagonal", text); + } + + string raw = Encoding.ASCII.GetString(bytes); + Assert.Contains("0.071 0.204 0.337 RG", raw, StringComparison.Ordinal); + Assert.Contains("0.396 0.263 0.129 RG", raw, StringComparison.Ordinal); + Assert.True(raw.Split(new[] { " S" }, StringSplitOptions.None).Length - 1 >= 10, "Expected Word double and diagonal borders to emit multiple stroked lines."); + Assert.True(raw.Contains(" m ", StringComparison.Ordinal) && raw.Contains(" l S", StringComparison.Ordinal), "Expected Word diagonal borders to emit PDF line segments."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Word_Style() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeWordStyledTable.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeWordStyledTable.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(2, 2); + table.Style = WordTableStyle.TableGrid; + table.Rows[0].Cells[0].Paragraphs[0].Text = "Styled grid"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "Header value"; + table.Rows[1].Cells[0].Paragraphs[0].Text = "Body label"; + table.Rows[1].Cells[1].Paragraphs[0].Text = "Body value"; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Styled grid", text); + Assert.Contains("Body value", text); + } + + string raw = Encoding.ASCII.GetString(bytes); + Assert.Contains("0 0 0 RG", raw); + Assert.Contains("0.5 w", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Level_Borders() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableLevelBorders.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableLevelBorders.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(2, 2); + table.Style = WordTableStyle.PlainTable1; + table.StyleDetails!.SetBordersForAllSides(BorderValues.Single, 12U, OfficeIMO.Drawing.OfficeColor.Red); + table.Rows[0].Cells[0].Paragraphs[0].Text = "Border A1"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "Border B1"; + table.Rows[1].Cells[0].Paragraphs[0].Text = "Border A2"; + table.Rows[1].Cells[1].Paragraphs[0].Text = "Border B2"; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Border A1", text); + Assert.Contains("Border B2", text); + } + + string raw = Encoding.ASCII.GetString(bytes); + Assert.Contains("1 0 0 RG", raw); + Assert.Contains("1.5 w", raw); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Default_Cell_Margins() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellMargins.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellMargins.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable plain = document.AddTable(1, 1); + ConfigureMarginTable(plain, "PlainPad"); + plain.StyleDetails!.MarginDefaultLeftWidth = 0; + + document.AddParagraph("between padding tables"); + + WordTable padded = document.AddTable(1, 1); + ConfigureMarginTable(padded, "WidePad"); + padded.StyleDetails!.MarginDefaultLeftWidth = 1000; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(400, 500), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var plainWord = Assert.Single(words, word => word.Text == "PlainPad"); + var paddedWord = Assert.Single(words, word => word.Text == "WidePad"); + + Assert.True(paddedWord.BoundingBox.Left > plainWord.BoundingBox.Left + 35D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Margins() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTablePerCellMargins.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTablePerCellMargins.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable plain = document.AddTable(1, 1); + ConfigureMarginTable(plain, "PlainCellPad"); + plain.StyleDetails!.MarginDefaultLeftWidth = 0; + + document.AddParagraph("between per-cell padding tables"); + + WordTable padded = document.AddTable(1, 1); + ConfigureMarginTable(padded, "WideCellPad"); + padded.StyleDetails!.MarginDefaultLeftWidth = 0; + padded.Rows[0].Cells[0].MarginLeftWidth = 1000; + padded.Rows[0].Cells[0].MarginTopWidth = 320; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(400, 500), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var plainWord = Assert.Single(words, word => word.Text == "PlainCellPad"); + var paddedWord = Assert.Single(words, word => word.Text == "WideCellPad"); + + Assert.True(paddedWord.BoundingBox.Left > plainWord.BoundingBox.Left + 35D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Spacing() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellSpacing.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableCellSpacing.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable plain = document.AddTable(1, 2); + ConfigureCellSpacingTable(plain, "PlainA", "PlainB"); + plain.StyleDetails!.CellSpacing = 0; + + document.AddParagraph("between spacing tables"); + + WordTable spaced = document.AddTable(1, 2); + ConfigureCellSpacingTable(spaced, "SpaceA", "SpaceB"); + spaced.StyleDetails!.CellSpacing = 240; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(420, 500), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var plainLeft = Assert.Single(words, word => word.Text == "PlainA"); + var plainRight = Assert.Single(words, word => word.Text == "PlainB"); + var spacedLeft = Assert.Single(words, word => word.Text == "SpaceA"); + var spacedRight = Assert.Single(words, word => word.Text == "SpaceB"); + + double plainGap = plainRight.BoundingBox.Left - plainLeft.BoundingBox.Left; + double spacedGap = spacedRight.BoundingBox.Left - spacedLeft.BoundingBox.Left; + Assert.True(spacedGap > plainGap + 10D, $"Expected Word table cell spacing to widen native table cell distance. Plain gap: {plainGap}; spaced gap: {spacedGap}."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Row_Height() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableRowHeight.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableRowHeight.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1); + table.Rows[0].Height = 1600; + table.Rows[0].Cells[0].Paragraphs[0].Text = "TallRow"; + document.AddParagraph("AfterTall"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(400, 500), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var tableWord = Assert.Single(words, word => word.Text == "TallRow"); + var followingWord = Assert.Single(words, word => word.Text == "AfterTall"); + + Assert.True(tableWord.BoundingBox.Bottom > followingWord.BoundingBox.Top + 45D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_NonUniform_Row_Heights() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableNonUniformRowHeights.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableNonUniformRowHeights.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(3, 1); + table.Rows[0].Height = 400; + table.Rows[0].Cells[0].Paragraphs[0].Text = "ShortA"; + table.Rows[1].Height = 1200; + table.Rows[1].Cells[0].Paragraphs[0].Text = "TallB"; + table.Rows[2].Height = 400; + table.Rows[2].Cells[0].Paragraphs[0].Text = "ShortC"; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(320, 260), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(30) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var shortA = Assert.Single(words, word => word.Text == "ShortA"); + var tallB = Assert.Single(words, word => word.Text == "TallB"); + var shortC = Assert.Single(words, word => word.Text == "ShortC"); + + double firstGap = shortA.BoundingBox.Bottom - tallB.BoundingBox.Bottom; + double secondGap = tallB.BoundingBox.Bottom - shortC.BoundingBox.Bottom; + Assert.True(secondGap > firstGap + 35D, $"Expected non-uniform Word row height to push the third row down. ShortA/TallB gap: {firstGap}; TallB/ShortC gap: {secondGap}."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Hyperlink() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeLinkedTable.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeLinkedTable.pdf"); + const string linkUri = "https://evotec.xyz/native-table-link"; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 1); + WordTableCell cell = table.Rows[0].Cells[0]; + cell.Paragraphs[0].AddHyperLink("Native table link", new Uri(linkUri), addStyle: true, tooltip: "Native table link metadata"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Native table link", text); + } + + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + PdfCore.PdfLinkAnnotation link = Assert.Single(info.LinkAnnotations); + Assert.Equal(linkUri, link.Uri); + Assert.Equal("Native table link metadata", link.Contents); + Assert.Equal(new[] { linkUri }, info.LinkUris); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Alignment() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeAlignedTable.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeAlignedTable.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(1, 3); + WordTableCell leftCell = table.Rows[0].Cells[0]; + WordTableCell centerCell = table.Rows[0].Cells[1]; + WordTableCell rightCell = table.Rows[0].Cells[2]; + + leftCell.Width = 1440; + leftCell.WidthType = TableWidthUnitValues.Dxa; + centerCell.Width = 1440; + centerCell.WidthType = TableWidthUnitValues.Dxa; + rightCell.Width = 1440; + rightCell.WidthType = TableWidthUnitValues.Dxa; + + leftCell.Paragraphs[0].Text = "TOP"; + leftCell.AddParagraph("PAD"); + leftCell.AddParagraph("PAD"); + leftCell.Paragraphs[0].ParagraphAlignment = JustificationValues.Left; + leftCell.Paragraphs[1].ParagraphAlignment = JustificationValues.Left; + leftCell.Paragraphs[2].ParagraphAlignment = JustificationValues.Left; + leftCell.VerticalAlignment = TableVerticalAlignmentValues.Top; + + centerCell.Paragraphs[0].Text = "MID"; + centerCell.Paragraphs[0].ParagraphAlignment = JustificationValues.Center; + centerCell.VerticalAlignment = TableVerticalAlignmentValues.Center; + + rightCell.Paragraphs[0].Text = "END"; + rightCell.Paragraphs[0].ParagraphAlignment = JustificationValues.Right; + rightCell.VerticalAlignment = TableVerticalAlignmentValues.Bottom; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var top = Assert.Single(words, word => word.Text == "TOP"); + var mid = Assert.Single(words, word => word.Text == "MID"); + var end = Assert.Single(words, word => word.Text == "END"); + + const double columnWidth = 72D; + double firstColumnLeft = top.BoundingBox.Left - 4D; + double secondColumnLeft = firstColumnLeft + columnWidth; + double thirdColumnLeft = secondColumnLeft + columnWidth; + + Assert.InRange(top.BoundingBox.Left, firstColumnLeft + 3D, firstColumnLeft + 8D); + Assert.InRange(mid.BoundingBox.Left, secondColumnLeft + 20D, secondColumnLeft + 36D); + Assert.InRange(end.BoundingBox.Right, thirdColumnLeft + columnWidth - 8D, thirdColumnLeft + columnWidth - 2D); + Assert.True(top.BoundingBox.Bottom > mid.BoundingBox.Bottom + 8D); + Assert.True(mid.BoundingBox.Bottom > end.BoundingBox.Bottom + 8D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_NonUniform_Alignment() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeNonUniformAlignedTable.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeNonUniformAlignedTable.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(2, 2); + foreach (WordTableRow row in table.Rows) { + row.Height = 1100; + foreach (WordTableCell cell in row.Cells) { + cell.Width = 1440; + cell.WidthType = TableWidthUnitValues.Dxa; + } + } + + table.Rows[0].Cells[0].Paragraphs[0].Text = "TopPeer"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "Left2"; + table.Rows[0].Cells[1].Paragraphs[0].ParagraphAlignment = JustificationValues.Left; + table.Rows[0].Cells[1].VerticalAlignment = TableVerticalAlignmentValues.Top; + + table.Rows[1].Cells[0].Paragraphs[0].Text = "TopCell"; + table.Rows[1].Cells[0].VerticalAlignment = TableVerticalAlignmentValues.Top; + table.Rows[1].Cells[1].Paragraphs[0].Text = "R2"; + table.Rows[1].Cells[1].Paragraphs[0].ParagraphAlignment = JustificationValues.Right; + table.Rows[1].Cells[1].VerticalAlignment = TableVerticalAlignmentValues.Bottom; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(360, 260), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(30) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var leftPeer = Assert.Single(words, word => word.Text == "Left2"); + var topCell = Assert.Single(words, word => word.Text == "TopCell"); + var rightBottom = Assert.Single(words, word => word.Text == "R2"); + + Assert.True(rightBottom.BoundingBox.Left > leftPeer.BoundingBox.Left + 35D, $"Expected non-uniform right-aligned cell to move right. Left2 x: {leftPeer.BoundingBox.Left}; R2 x: {rightBottom.BoundingBox.Left}."); + Assert.True(topCell.BoundingBox.Bottom > rightBottom.BoundingBox.Bottom + 20D, $"Expected non-uniform bottom-aligned cell to move down inside the same row. TopCell bottom: {topCell.BoundingBox.Bottom}; R2 bottom: {rightBottom.BoundingBox.Bottom}."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Merged_Cells() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeMergedTable.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeMergedTable.pdf"); + const string horizontalUri = "https://evotec.xyz/native-table-column-span"; + const string verticalUri = "https://evotec.xyz/native-table-row-span"; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(3, 3); + foreach (WordTableRow row in table.Rows) { + foreach (WordTableCell cell in row.Cells) { + cell.Width = 1440; + cell.WidthType = TableWidthUnitValues.Dxa; + } + } + + table.Rows[0].Cells[0].Paragraphs[0].AddHyperLink("Across", new Uri(horizontalUri), addStyle: true, tooltip: "Column span metadata"); + table.Rows[0].Cells[0].Paragraphs[0].ParagraphAlignment = JustificationValues.Center; + table.Rows[0].Cells[2].Paragraphs[0].Text = "TopTail"; + + table.Rows[1].Cells[0].Paragraphs[0].AddHyperLink("Tall", new Uri(verticalUri), addStyle: true, tooltip: "Row span metadata"); + table.Rows[1].Cells[0].VerticalAlignment = TableVerticalAlignmentValues.Center; + table.Rows[1].Cells[1].Paragraphs[0].Text = "Upper"; + table.Rows[1].Cells[2].Paragraphs[0].Text = "UpperTail"; + table.Rows[2].Cells[1].Paragraphs[0].Text = "Lower"; + table.Rows[2].Cells[2].Paragraphs[0].Text = "LowerTail"; + + table.Rows[0].Cells[0].MergeHorizontally(1); + table.Rows[1].Cells[0].MergeVertically(1); + + Assert.Equal(2, table.Rows[0].Cells[0].ColumnSpan); + Assert.Equal(2, table.Rows[1].Cells[0].RowSpan); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using (PdfDocument pdf = PdfDocument.Open(bytes)) { + string text = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Across", text); + Assert.Contains("Tall", text); + Assert.Contains("TopTail", text); + Assert.Contains("Upper", text); + Assert.Contains("Lower", text); + } + + PdfCore.PdfDocumentInfo info = PdfCore.PdfInspector.Inspect(bytes); + PdfCore.PdfLinkAnnotation horizontal = Assert.Single(info.LinkAnnotations, link => link.Uri == horizontalUri); + PdfCore.PdfLinkAnnotation vertical = Assert.Single(info.LinkAnnotations, link => link.Uri == verticalUri); + Assert.Equal("Column span metadata", horizontal.Contents); + Assert.Equal("Row span metadata", vertical.Contents); + Assert.True(horizontal.Width > 110D); + Assert.True(vertical.Height > 30D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Header_Row() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeRepeatingHeaderTable.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeRepeatingHeaderTable.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(46, 2); + foreach (WordTableRow row in table.Rows) { + foreach (WordTableCell cell in row.Cells) { + cell.Width = 1440; + cell.WidthType = TableWidthUnitValues.Dxa; + } + } + + table.Rows[0].Cells[0].Paragraphs[0].Text = "RepeatHdr"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "ValueHdr"; + table.RepeatAsHeaderRowAtTheTopOfEachPage = true; + + for (int rowIndex = 1; rowIndex < table.Rows.Count; rowIndex++) { + table.Rows[rowIndex].Cells[0].Paragraphs[0].Text = "Row " + rowIndex.ToString("D2"); + table.Rows[rowIndex].Cells[1].Paragraphs[0].Text = "Value " + rowIndex.ToString("D2"); + } + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(260, 220), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(12) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.True(pdf.NumberOfPages > 1); + + int repeatedHeaderCount = pdf.GetPages() + .SelectMany(page => page.GetWords()) + .Count(word => word.Text == "RepeatHdr"); + Assert.True(repeatedHeaderCount >= 2); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Multiple_Header_Rows() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeMultipleRepeatingHeaderRows.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeMultipleRepeatingHeaderRows.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(44, 2); + foreach (WordTableRow row in table.Rows) { + foreach (WordTableCell cell in row.Cells) { + cell.Width = 1440; + cell.WidthType = TableWidthUnitValues.Dxa; + } + } + + table.Rows[0].RepeatHeaderRowAtTheTopOfEachPage = true; + table.Rows[1].RepeatHeaderRowAtTheTopOfEachPage = true; + table.Rows[0].Cells[0].Paragraphs[0].Text = "HdrA"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "HdrB"; + table.Rows[1].Cells[0].Paragraphs[0].Text = "HdrC"; + table.Rows[1].Cells[1].Paragraphs[0].Text = "HdrD"; + + for (int rowIndex = 2; rowIndex < table.Rows.Count; rowIndex++) { + table.Rows[rowIndex].Cells[0].Paragraphs[0].Text = "Metric " + rowIndex.ToString("D2", System.Globalization.CultureInfo.InvariantCulture); + table.Rows[rowIndex].Cells[1].Paragraphs[0].Text = "Owner " + rowIndex.ToString("D2", System.Globalization.CultureInfo.InvariantCulture); + } + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(260, 220), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(12) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.True(pdf.NumberOfPages > 1); + + int firstHeaderCount = pdf.GetPages() + .SelectMany(page => page.GetWords()) + .Count(word => word.Text == "HdrA"); + int secondHeaderCount = pdf.GetPages() + .SelectMany(page => page.GetWords()) + .Count(word => word.Text == "HdrC"); + + Assert.True(firstHeaderCount >= 2); + Assert.True(secondHeaderCount >= 2); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_First_Row_Style_Without_Repeating() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeFirstRowStyleNoRepeat.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeFirstRowStyleNoRepeat.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable table = document.AddTable(44, 2, WordTableStyle.GridTable1Light); + table.ConditionalFormattingFirstRow = true; + table.Rows[0].RepeatHeaderRowAtTheTopOfEachPage = false; + foreach (WordTableRow row in table.Rows) { + foreach (WordTableCell cell in row.Cells) { + cell.Width = 1440; + cell.WidthType = TableWidthUnitValues.Dxa; + } + } + + table.Rows[0].Cells[0].Paragraphs[0].Text = "SoloHdr"; + table.Rows[0].Cells[1].Paragraphs[0].Text = "SoloValue"; + for (int rowIndex = 1; rowIndex < table.Rows.Count; rowIndex++) { + table.Rows[rowIndex].Cells[0].Paragraphs[0].Text = "Body " + rowIndex.ToString("D2", System.Globalization.CultureInfo.InvariantCulture); + table.Rows[rowIndex].Cells[1].Paragraphs[0].Text = "Value " + rowIndex.ToString("D2", System.Globalization.CultureInfo.InvariantCulture); + } + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(260, 220), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(12) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + Assert.True(pdf.NumberOfPages > 1); + + int firstRowHeaderCount = pdf.GetPages() + .SelectMany(page => page.GetWords()) + .Count(word => word.Text == "SoloHdr"); + Assert.Equal(1, firstRowHeaderCount); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Placement() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTablePlacement.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTablePlacement.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable leftTable = document.AddTable(1, 2); + ConfigurePlacementTable(leftTable, "LeftTbl", TableRowAlignmentValues.Left); + + document.AddParagraph("between left and center"); + + WordTable centerTable = document.AddTable(1, 2); + ConfigurePlacementTable(centerTable, "CenterTbl", TableRowAlignmentValues.Center); + + document.AddParagraph("between center and right"); + + WordTable rightTable = document.AddTable(1, 2); + ConfigurePlacementTable(rightTable, "RightTbl", TableRowAlignmentValues.Right); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(400, 500), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var left = Assert.Single(words, word => word.Text == "LeftTbl"); + var center = Assert.Single(words, word => word.Text == "CenterTbl"); + var right = Assert.Single(words, word => word.Text == "RightTbl"); + + Assert.True(center.BoundingBox.Left > left.BoundingBox.Left + 70D); + Assert.True(right.BoundingBox.Left > center.BoundingBox.Left + 70D); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Preferred_Dxa_Width() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTablePreferredWidth.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTablePreferredWidth.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + WordTable preferred = document.AddTable(1, 2); + preferred.LayoutType = TableLayoutValues.Fixed; + preferred.WidthType = TableWidthUnitValues.Dxa; + preferred.Width = 2160; + preferred.Rows[0].Cells[0].Paragraphs[0].Text = "NA"; + preferred.Rows[0].Cells[1].Paragraphs[0].Text = "NB"; + + document.AddParagraph("between width tables"); + + WordTable defaultWidth = document.AddTable(1, 2); + defaultWidth.Rows[0].Cells[0].Paragraphs[0].Text = "FA"; + defaultWidth.Rows[0].Cells[1].Paragraphs[0].Text = "FB"; + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false, + OfficeIMOPageSize = new PdfCore.PageSize(400, 500), + OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + }); + } + + byte[] bytes = File.ReadAllBytes(pdfPath); + using PdfDocument pdf = PdfDocument.Open(bytes); + var words = pdf.GetPage(1).GetWords().ToList(); + var narrowLeft = Assert.Single(words, word => word.Text == "NA"); + var narrowRight = Assert.Single(words, word => word.Text == "NB"); + var defaultLeft = Assert.Single(words, word => word.Text == "FA"); + var defaultRight = Assert.Single(words, word => word.Text == "FB"); + + double preferredGap = narrowRight.BoundingBox.Left - narrowLeft.BoundingBox.Left; + double defaultGap = defaultRight.BoundingBox.Left - defaultLeft.BoundingBox.Left; + Assert.True(preferredGap < defaultGap - 40D, $"Expected preferred DXA table width to narrow the native table. Preferred gap: {preferredGap}; default gap: {defaultGap}."); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Preferred_Width_And_AutoFit_Style() { + using WordDocument document = WordDocument.Create(Path.Combine(_directoryWithFiles, "PdfNativeTableLayoutStyle.docx")); + + WordTable preferred = document.AddTable(1, 2); + preferred.WidthType = TableWidthUnitValues.Dxa; + preferred.Width = 2880; + PdfCore.PdfTableStyle preferredStyle = CreateNativeTableStyleForTest(preferred); + + Assert.Equal(144D, preferredStyle.MaxWidth); + Assert.Equal(10D, preferredStyle.FontSize); + Assert.False(preferredStyle.AutoFitColumns); + + WordTable autoFit = document.AddTable(1, 2); + autoFit.Rows[0].Cells[0].Paragraphs[0].Text = "Short"; + autoFit.Rows[0].Cells[1].Paragraphs[0].Text = "Much wider auto fit text"; + autoFit.AutoFitToContents(); + PdfCore.PdfTableStyle autoFitStyle = CreateNativeTableStyleForTest(autoFit); + + Assert.True(autoFitStyle.AutoFitColumns); + Assert.Null(autoFitStyle.MaxWidth); + + WordTable spaced = document.AddTable(1, 2); + spaced.StyleDetails!.CellSpacing = 240; + PdfCore.PdfTableStyle spacedStyle = CreateNativeTableStyleForTest(spaced); + + Assert.Equal(12D, spacedStyle.CellSpacing); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Row_Break_Policies() { + using WordDocument document = WordDocument.Create(Path.Combine(_directoryWithFiles, "PdfNativeTableRowBreakPolicies.docx")); + WordTable table = document.AddTable(2, 1); + table.Rows[0].AllowRowToBreakAcrossPages = false; + table.Rows[1].AllowRowToBreakAcrossPages = true; + + PdfCore.PdfTableStyle style = CreateNativeTableStyleForTest(table); + + Assert.True(style.AllowRowBreakAcrossPages); + Assert.NotNull(style.RowAllowBreakAcrossPages); + Assert.False(style.RowAllowBreakAcrossPages![0]); + Assert.True(style.RowAllowBreakAcrossPages![1]); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Multiple_Header_Rows() { + using WordDocument document = WordDocument.Create(Path.Combine(_directoryWithFiles, "PdfNativeTableMultipleHeaderRows.docx")); + WordTable table = document.AddTable(4, 1); + table.Rows[0].RepeatHeaderRowAtTheTopOfEachPage = true; + table.Rows[1].RepeatHeaderRowAtTheTopOfEachPage = true; + table.Rows[3].RepeatHeaderRowAtTheTopOfEachPage = true; + + PdfCore.PdfTableStyle style = CreateNativeTableStyleForTest(table); + + Assert.Equal(2, style.HeaderRowCount); + Assert.Equal(2, style.RepeatHeaderRowCount); + } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_Table_First_Row_Style_Without_Repeating() { + using WordDocument document = WordDocument.Create(Path.Combine(_directoryWithFiles, "PdfNativeTableFirstRowStyleNoRepeat.docx")); + WordTable table = document.AddTable(3, 1, WordTableStyle.GridTable1Light); + table.ConditionalFormattingFirstRow = true; + table.Rows[0].RepeatHeaderRowAtTheTopOfEachPage = false; + + PdfCore.PdfTableStyle style = CreateNativeTableStyleForTest(table); + + Assert.Equal(1, style.HeaderRowCount); + Assert.Equal(0, style.RepeatHeaderRowCount); + } + + private static void ConfigureMarginTable(WordTable table, string label) { + WordTableCell cell = table.Rows[0].Cells[0]; + cell.Width = 2880; + cell.WidthType = TableWidthUnitValues.Dxa; + cell.Paragraphs[0].Text = label; + } + + private static void ConfigurePlacementTable(WordTable table, string label, TableRowAlignmentValues alignment) { + table.Alignment = alignment; + foreach (WordTableCell cell in table.Rows[0].Cells) { + cell.Width = 1440; + cell.WidthType = TableWidthUnitValues.Dxa; + } + + table.Rows[0].Cells[0].Paragraphs[0].Text = label; + table.Rows[0].Cells[1].Paragraphs[0].Text = "Value"; + } + + private static void ConfigureCellSpacingTable(WordTable table, string left, string right) { + foreach (WordTableCell cell in table.Rows[0].Cells) { + cell.Width = 1440; + cell.WidthType = TableWidthUnitValues.Dxa; + } + + table.Rows[0].Cells[0].Paragraphs[0].Text = left; + table.Rows[0].Cells[1].Paragraphs[0].Text = right; + } + + private static PdfCore.PdfTableStyle CreateNativeTableStyleForTest(WordTable table) { + MethodInfo method = typeof(WordPdfConverterExtensions).GetMethod("CreateNativeTableStyle", BindingFlags.NonPublic | BindingFlags.Static)!; + return Assert.IsType(method.Invoke(null, new object?[] { table, table.Rows.Count, null })); + } } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs index d87214dab..b5263de80 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs @@ -1,13 +1,12 @@ using DocumentFormat.OpenXml.Wordprocessing; using OfficeIMO.Word; using OfficeIMO.Word.Pdf; -using QuestPDF.Helpers; -using QuestPDF.Infrastructure; using System.Globalization; -using System.IO.Compression; using System.Text; using System.Text.RegularExpressions; +using UglyToad.PdfPig; using Xunit; +using PdfCore = OfficeIMO.Pdf; namespace OfficeIMO.Tests; @@ -191,8 +190,7 @@ public void Test_WordDocument_SaveAsPdf_NullDocument_Throws() { [Fact] public void Test_WordDocument_SaveAsPdf_CustomParagraphFont() { - string font = FontResolver.Resolve("monospace")!; - string expected = Regex.Replace(font, @"\s+", ""); + string font = "Courier New"; string docPath = Path.Combine(_directoryWithFiles, "PdfCustomParagraphFont.docx"); string pdfPath = Path.Combine(_directoryWithFiles, "PdfCustomParagraphFont.pdf"); @@ -204,14 +202,12 @@ public void Test_WordDocument_SaveAsPdf_CustomParagraphFont() { } Assert.True(File.Exists(pdfPath)); - string pdfContent = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); - Assert.Contains(expected, pdfContent); + AssertPdfUsesFont(pdfPath, "Courier"); } [Fact] public void Test_WordDocument_SaveAsPdf_CustomDefaultFont() { - string font = FontResolver.Resolve("monospace")!; - string expected = Regex.Replace(font, @"\s+", ""); + string font = "Times New Roman"; string docPath = Path.Combine(_directoryWithFiles, "PdfCustomDefaultFont.docx"); string pdfPath = Path.Combine(_directoryWithFiles, "PdfCustomDefaultFont.pdf"); @@ -222,8 +218,23 @@ public void Test_WordDocument_SaveAsPdf_CustomDefaultFont() { } Assert.True(File.Exists(pdfPath)); - string pdfContent = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); - Assert.Contains(expected, pdfContent); + AssertPdfUsesFont(pdfPath, "Times"); + } + + [Fact] + public void Test_WordDocument_SaveAsPdf_DocumentDefaultFont() { + string docPath = Path.Combine(_directoryWithFiles, "PdfDocumentDefaultFont.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfDocumentDefaultFont.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.Settings.FontFamily = "Consolas"; + document.AddParagraph("Hello document default font"); + document.Save(); + document.SaveAsPdf(pdfPath); + } + + Assert.True(File.Exists(pdfPath)); + AssertPdfUsesFont(pdfPath, "Courier"); } [Theory] @@ -239,7 +250,7 @@ public void Test_WordDocument_SaveAsPdf_PageOrientation(PdfPageOrientation orien document.SaveAsPdf(pdfPath, new PdfSaveOptions { Orientation = orientation, - PageSize = PageSizes.A4 + OfficeIMOPageSize = PdfCore.PageSizes.A4 }); } @@ -289,13 +300,13 @@ public void Test_WordDocument_SaveAsPdf_SectionOrientationWithoutPageSize(string public void Test_WordDocument_SaveAsPdf_CustomPageSize() { string docPath = Path.Combine(_directoryWithFiles, "PdfCustomSize.docx"); string pdfPath = Path.Combine(_directoryWithFiles, "PdfCustomSize.pdf"); - QuestPDF.Helpers.PageSize size = new QuestPDF.Helpers.PageSize(300, 500); + PdfCore.PageSize size = new PdfCore.PageSize(300, 500); using (WordDocument document = WordDocument.Create(docPath)) { document.AddParagraph("Hello World"); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - PageSize = size + OfficeIMOPageSize = size }); } @@ -320,43 +331,16 @@ public void Test_WordDocument_SaveAsPdf_CustomMargins() { document.AddParagraph("Hello World"); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - Margin = (float)marginCm, - MarginUnit = Unit.Centimetre + OfficeIMOMargins = PdfCore.PageMargins.UniformCentimeters(marginCm) }); } Assert.True(File.Exists(pdfPath)); - byte[] bytes = File.ReadAllBytes(pdfPath); - byte[] startPattern = Encoding.ASCII.GetBytes("stream\n"); - byte[] endPattern = Encoding.ASCII.GetBytes("\nendstream"); - int start = IndexOf(bytes, startPattern, 0); - Assert.True(start >= 0, "stream marker not found"); - start += startPattern.Length; - int end = IndexOf(bytes, endPattern, start); - Assert.True(end >= 0, "endstream marker not found"); - int length = end - start; - // remove zlib header (2 bytes) and Adler32 checksum (last 4 bytes) - int deflateLength = length - 6; - byte[] deflateData = new byte[deflateLength]; - Array.Copy(bytes, start + 2, deflateData, 0, deflateLength); - using MemoryStream ms = new MemoryStream(deflateData); - using DeflateStream ds = new DeflateStream(ms, CompressionMode.Decompress); - using StreamReader reader = new StreamReader(ds, Encoding.GetEncoding("ISO-8859-1")); - string content = reader.ReadToEnd(); - - MatchCollection matches = Regex.Matches(content, @"4 0 0 4 (?[0-9\.]+) (?[0-9\.]+) cm"); - Assert.True(matches.Count > 0, "Margin transform not found"); - double value = 0; - foreach (Match m in matches) { - value = double.Parse(m.Groups["x"].Value, CultureInfo.InvariantCulture); - if (value > 0) { - break; - } - } - double marginPoints = value / 4.0; - double resultMarginCm = marginPoints / 28.3464566929; - Assert.True(System.Math.Abs(resultMarginCm - marginCm) < 0.1); + using PdfDocument pdf = PdfDocument.Open(pdfPath); + var hello = Assert.Single(pdf.GetPage(1).GetWords(), word => word.Text == "Hello"); + double expectedMarginPoints = marginCm * 72D / 2.54D; + Assert.InRange(hello.BoundingBox.Left, expectedMarginPoints - 2D, expectedMarginPoints + 4D); } [Fact] @@ -369,39 +353,16 @@ public void Test_WordDocument_SaveAsPdf_MixedMargins() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - Margin = 2, - MarginUnit = Unit.Centimetre, - MarginLeft = 1, - MarginLeftUnit = Unit.Centimetre, - MarginTop = 3, - MarginTopUnit = Unit.Centimetre + OfficeIMOMargins = PdfCore.PageMargins.FromCentimeters(1, 3, 2, 2) }); } Assert.True(File.Exists(pdfPath)); - byte[] bytes = File.ReadAllBytes(pdfPath); - byte[] startPattern = Encoding.ASCII.GetBytes("stream\n"); - byte[] endPattern = Encoding.ASCII.GetBytes("\nendstream"); - int start = IndexOf(bytes, startPattern, 0); - Assert.True(start >= 0, "stream marker not found"); - start += startPattern.Length; - int end = IndexOf(bytes, endPattern, start); - Assert.True(end >= 0, "endstream marker not found"); - int length = end - start; - int deflateLength = length - 6; - byte[] deflateData = new byte[deflateLength]; - Array.Copy(bytes, start + 2, deflateData, 0, deflateLength); - using MemoryStream ms = new MemoryStream(deflateData); - using DeflateStream ds = new DeflateStream(ms, CompressionMode.Decompress); - using StreamReader reader = new StreamReader(ds, Encoding.GetEncoding("ISO-8859-1")); - string content = reader.ReadToEnd(); - - Match transform = Regex.Match(content, @"4 0 0 4 (?[0-9\.]+) (?[0-9\.]+) cm"); - Assert.True(transform.Success, "Margin transform not found"); - double leftMarginCm = double.Parse(transform.Groups["x"].Value, CultureInfo.InvariantCulture) / 4.0 / 28.3464566929; - - Assert.True(System.Math.Abs(leftMarginCm - 1) < 0.1); + using PdfDocument pdf = PdfDocument.Open(pdfPath); + var hello = Assert.Single(pdf.GetPage(1).GetWords(), word => word.Text == "Hello"); + double expectedLeftMarginPoints = 72D / 2.54D; + Assert.InRange(hello.BoundingBox.Left, expectedLeftMarginPoints - 2D, expectedLeftMarginPoints + 4D); } [Fact] @@ -449,14 +410,14 @@ public void Test_WordDocument_SaveAsPdf_SectionPageSettings() { double w1 = double.Parse(boxes[0].Groups["w"].Value, CultureInfo.InvariantCulture); double h1 = double.Parse(boxes[0].Groups["h"].Value, CultureInfo.InvariantCulture); - QuestPDF.Helpers.PageSize a4 = PageSizes.A4.Landscape(); + PdfCore.PageSize a4 = PdfCore.PageSizes.A4.Landscape(); Assert.True(System.Math.Abs(w1 - a4.Width) < 1); Assert.True(System.Math.Abs(h1 - a4.Height) < 1); double w2 = double.Parse(boxes[1].Groups["w"].Value, CultureInfo.InvariantCulture); double h2 = double.Parse(boxes[1].Groups["h"].Value, CultureInfo.InvariantCulture); - Assert.True(System.Math.Abs(w2 - PageSizes.A5.Width) < 1); - Assert.True(System.Math.Abs(h2 - PageSizes.A5.Height) < 1); + Assert.True(System.Math.Abs(w2 - PdfCore.PageSizes.A5.Width) < 1); + Assert.True(System.Math.Abs(h2 - PdfCore.PageSizes.A5.Height) < 1); Assert.True(w1 > h1); Assert.True(h2 > w2); @@ -478,8 +439,11 @@ public void Test_WordDocument_SaveAsPdf_BookmarkLink() { Assert.True(File.Exists(pdfPath)); - string pdfContent = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); - Assert.Contains("/Dest /0#20|#20TargetBookmark", pdfContent); + PdfCore.PdfLogicalDocument logical = PdfCore.PdfLogicalDocument.Load(File.ReadAllBytes(pdfPath), new PdfCore.PdfTextLayoutOptions { + ForceSingleColumn = true + }); + Assert.Contains(logical.NamedDestinations, destination => destination.Name == "TargetBookmark"); + Assert.Contains(logical.GetLinksByDestinationName("TargetBookmark"), link => link.IsNamedDestinationLink); } private static int IndexOf(byte[] buffer, byte[] pattern, int start) { @@ -496,4 +460,14 @@ private static int IndexOf(byte[] buffer, byte[] pattern, int start) { } return -1; } + + private static void AssertPdfUsesFont(string pdfPath, string expectedFontNamePart) { + using PdfDocument pdf = PdfDocument.Open(pdfPath); + Assert.Contains(pdf.GetPage(1).Letters, letter => + letter.FontName != null && + letter.FontName.Contains(expectedFontNamePart, StringComparison.OrdinalIgnoreCase)); + + string pdfContent = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains("/BaseFont /" + expectedFontNamePart, pdfContent, StringComparison.OrdinalIgnoreCase); + } } diff --git a/OfficeIMO.Tests/Word.Tables.cs b/OfficeIMO.Tests/Word.Tables.cs index fb9b2f9f2..ee0096983 100644 --- a/OfficeIMO.Tests/Word.Tables.cs +++ b/OfficeIMO.Tests/Word.Tables.cs @@ -34,6 +34,9 @@ public void Test_CreatingWordDocumentWithTables() { Assert.True(row.RepeatHeaderRowAtTheTopOfEachPage == false); } + wordTable.Rows[1].RepeatHeaderRowAtTheTopOfEachPage = false; + Assert.True(wordTable.Rows[1].RepeatHeaderRowAtTheTopOfEachPage == false); + wordTable.RepeatHeaderRowAtTheTopOfEachPage = true; Assert.True(wordTable.RepeatHeaderRowAtTheTopOfEachPage == true); diff --git a/OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj b/OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj index 1b57fc04b..f668ed1d8 100644 --- a/OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj +++ b/OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj @@ -1,6 +1,6 @@ - PDF converter for OfficeIMO.Word - Export Word documents to PDF using QuestPDF + PDF converter for OfficeIMO.Word - Export Word documents to PDF using the first-party OfficeIMO.Pdf engine. OfficeIMO.Word.Pdf OfficeIMO.Word.Pdf OfficeIMO.Word.Pdf @@ -10,7 +10,7 @@ false Evotec Przemyslaw Klys - pdf;openxml;office;docx;questpdf;skiasharp + pdf;openxml;office;docx;officeimo-pdf https://github.com/EvotecIT/OfficeIMO MIT https://github.com/EvotecIT/OfficeIMO @@ -33,15 +33,9 @@ + - - - - - - - @@ -51,7 +45,4 @@ - - - diff --git a/OfficeIMO.Word.Pdf/PdfExportWarning.cs b/OfficeIMO.Word.Pdf/PdfExportWarning.cs new file mode 100644 index 000000000..9b7913a55 --- /dev/null +++ b/OfficeIMO.Word.Pdf/PdfExportWarning.cs @@ -0,0 +1,38 @@ +namespace OfficeIMO.Word.Pdf { + /// + /// Describes content that could not be faithfully mapped during PDF export. + /// + public sealed class PdfExportWarning { + /// + /// Stable warning code suitable for assertions and wrapper routing. + /// + public string Code { get; } + + /// + /// Document location or feature area where the warning was produced. + /// + public string Source { get; } + + /// + /// Human-readable warning message. + /// + public string Message { get; } + + /// + /// Creates a new export warning. + /// + /// Stable warning code. + /// Document location or feature area. + /// Human-readable warning message. + public PdfExportWarning(string code, string source, string message) { + Code = code ?? throw new System.ArgumentNullException(nameof(code)); + Source = source ?? throw new System.ArgumentNullException(nameof(source)); + Message = message ?? throw new System.ArgumentNullException(nameof(message)); + } + + /// + /// Returns a readable representation of the warning. + /// + public override string ToString() => Code + ": " + Message; + } +} diff --git a/OfficeIMO.Word.Pdf/PdfSaveOptions.cs b/OfficeIMO.Word.Pdf/PdfSaveOptions.cs index 308f57da6..a910951f7 100644 --- a/OfficeIMO.Word.Pdf/PdfSaveOptions.cs +++ b/OfficeIMO.Word.Pdf/PdfSaveOptions.cs @@ -1,85 +1,31 @@ -using QuestPDF.Helpers; -using QuestPDF.Infrastructure; using System.Collections.Generic; +using PdfCore = OfficeIMO.Pdf; namespace OfficeIMO.Word.Pdf { /// - /// Options controlling PDF export. + /// Options controlling first-party OfficeIMO PDF export. /// public class PdfSaveOptions { /// - /// Optional font family applied to created runs during conversion. + /// Optional Word-style font family used as the first-party PDF default font when it maps to Helvetica, Times, or Courier standard PDF families. /// public string? FontFamily { get; set; } - /// - /// Optional mapping of font family names to font file paths. - /// - public Dictionary? FontFilePaths { get; set; } /// - /// Optional mapping of font family names to font data streams. + /// Optional first-party page size in PDF points. The supplied geometry is preserved unless is also set. /// - public Dictionary? FontStreams { get; set; } + public PdfCore.PageSize? OfficeIMOPageSize { get; set; } + /// - /// Optional page size for the generated PDF. + /// Optional first-party page margins in PDF points. /// - public PageSize? PageSize { get; set; } + public PdfCore.PageMargins? OfficeIMOMargins { get; set; } /// /// Optional page orientation for the generated PDF. /// public PdfPageOrientation? Orientation { get; set; } - /// - /// Optional page margins for the generated PDF. - /// - public float? Margin { get; set; } - - /// - /// Measurement unit for the margin value. - /// - public Unit MarginUnit { get; set; } = Unit.Centimetre; - - /// - /// Optional left page margin for the generated PDF. - /// - public float? MarginLeft { get; set; } - - /// - /// Measurement unit for the left margin value. - /// - public Unit MarginLeftUnit { get; set; } = Unit.Centimetre; - - /// - /// Optional right page margin for the generated PDF. - /// - public float? MarginRight { get; set; } - - /// - /// Measurement unit for the right margin value. - /// - public Unit MarginRightUnit { get; set; } = Unit.Centimetre; - - /// - /// Optional top page margin for the generated PDF. - /// - public float? MarginTop { get; set; } - - /// - /// Measurement unit for the top margin value. - /// - public Unit MarginTopUnit { get; set; } = Unit.Centimetre; - - /// - /// Optional bottom page margin for the generated PDF. - /// - public float? MarginBottom { get; set; } - - /// - /// Measurement unit for the bottom margin value. - /// - public Unit MarginBottomUnit { get; set; } = Unit.Centimetre; - /// /// Optional default page size applied when creating new documents. /// @@ -111,9 +57,10 @@ public class PdfSaveOptions { public string? Keywords { get; set; } /// - /// Optional QuestPDF license type used when generating the PDF. + /// Warnings populated when content cannot be mapped faithfully. + /// The collection is cleared at the start of each export. /// - public LicenseType? QuestPdfLicenseType { get; set; } + public List Warnings { get; } = new List(); /// /// Determines whether page numbers are rendered in the PDF footer. Defaults to true. diff --git a/OfficeIMO.Word.Pdf/README.md b/OfficeIMO.Word.Pdf/README.md index d256fb721..38c61a0bc 100644 --- a/OfficeIMO.Word.Pdf/README.md +++ b/OfficeIMO.Word.Pdf/README.md @@ -2,5 +2,7 @@ Utilities bridging OfficeIMO outputs with PDF workflows. +`OfficeIMO.Word.Pdf` defaults to the first-party `OfficeIMO.Pdf` exporter. Use `PdfSaveOptions.OfficeIMOPageSize` and `OfficeIMOMargins` for page setup; they use `OfficeIMO.Pdf.PageSize` and `OfficeIMO.Pdf.PageMargins` in PDF points, and `OfficeIMOPageSize` is preserved as exact page geometry unless `PdfSaveOptions.Orientation` is also set. The native path maps Word document background color, common Word/PDF font-family requests to standard Helvetica, Times, and Courier PDF families for document defaults and paragraph/run text, scoped Word paragraph/run font sizes, superscript/subscript baseline, highlights, justified paragraphs, text-wrapping breaks, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, Word horizontal lines, paragraph top/bottom border rules, simple non-uniform paragraph side borders, uniform/non-uniform, double, and diagonal table cell borders, default and per-cell table margins, uniform/non-uniform table row heights, row-level table break policies, separated first-row visual table styling and contiguous leading repeated table header rows, uniform column and non-uniform per-cell table alignment, fixed-width Word tables fit into narrower native PDF column frames, footnote/endnote markers with simple note text, simple text boxes, simple VML shapes plus the DrawingML preset shapes exposed by `WordShape`, simple body, table-cell, header, and footer picture content controls, simple body, table-cell, header, and footer repeating-section text items, simple inline body/table/header/footer text content controls, simple body and table-cell Word check boxes as first-party PDF AcroForm check boxes, simple body-level and table-cell Word dropdown, combo box, and date picker controls as first-party PDF AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple body/table-cell/header/footer OMML equation text as static first-party text, Word section page-number starts/styles, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, simple header/footer paragraph alignment, simple header/footer paragraph and table-cell images/shapes, simple header/footer table-cell zones, and rich table-cell runs into first-party rich text primitives, records `PdfSaveOptions.Warnings` for unsupported native header/footer and body content that cannot be mapped faithfully yet, and has initial Poppler raster baselines for Word-origin report, daily-layout, and table-cell picture-control fixtures. + Examples are available in `OfficeIMO.Examples`. diff --git a/OfficeIMO.Word.Pdf/TableLayoutCache.cs b/OfficeIMO.Word.Pdf/TableLayoutCache.cs index bf290e480..57d877521 100644 --- a/OfficeIMO.Word.Pdf/TableLayoutCache.cs +++ b/OfficeIMO.Word.Pdf/TableLayoutCache.cs @@ -12,12 +12,25 @@ internal static TableLayout GetLayout(WordTable table) { } List> rows = TableBuilder.Map(table).ToList(); - int columnCount = rows.Max(r => r.Count); + int columnCount = ResolveColumnCount(table, rows); float[] widths = new float[columnCount]; + List gridColumnWidths = table.GridColumnWidth; + if (gridColumnWidths.Count > 0) { + for (int i = 0; i < widths.Length && i < gridColumnWidths.Count; i++) { + widths[i] = gridColumnWidths[i] / 20f; + } + } + foreach (IReadOnlyList row in rows) { - for (int i = 0; i < row.Count; i++) { + int logicalColumn = 0; + for (int i = 0; i < row.Count && logicalColumn < widths.Length; i++) { WordTableCell cell = row[i]; + if (cell.HorizontalMerge == MergedCellValues.Continue) { + continue; + } + + int columnSpan = System.Math.Max(1, cell.ColumnSpan); float width = 0f; if (cell.Width.HasValue && cell.WidthType == TableWidthUnitValues.Dxa) { width = cell.Width.Value / 20f; @@ -33,9 +46,16 @@ internal static TableLayout GetLayout(WordTable table) { } } - if (width > widths[i]) { - widths[i] = width; + if (width > 0f) { + float widthPerColumn = width / columnSpan; + for (int columnIndex = logicalColumn; columnIndex < logicalColumn + columnSpan && columnIndex < widths.Length; columnIndex++) { + if (widthPerColumn > widths[columnIndex]) { + widths[columnIndex] = widthPerColumn; + } + } } + + logicalColumn += columnSpan; } } @@ -43,6 +63,31 @@ internal static TableLayout GetLayout(WordTable table) { _cache.Add(table, layout); return layout; } + + private static int ResolveColumnCount(WordTable table, List> rows) { + List gridColumnWidths = table.GridColumnWidth; + if (gridColumnWidths.Count > 0) { + return gridColumnWidths.Count; + } + + int columnCount = 0; + foreach (IReadOnlyList row in rows) { + int rowColumns = 0; + foreach (WordTableCell cell in row) { + if (cell.HorizontalMerge == MergedCellValues.Continue) { + continue; + } + + rowColumns += System.Math.Max(1, cell.ColumnSpan); + } + + if (rowColumns > columnCount) { + columnCount = rowColumns; + } + } + + return columnCount; + } } } diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Helpers.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Helpers.cs deleted file mode 100644 index 923d276f1..000000000 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Helpers.cs +++ /dev/null @@ -1,19 +0,0 @@ -using QuestPDF.Helpers; - -namespace OfficeIMO.Word.Pdf { - public static partial class WordPdfConverterExtensions { - private static PageSize MapToPageSize(WordPageSize pageSize) { - return pageSize switch { - WordPageSize.Letter => PageSizes.Letter, - WordPageSize.Legal => PageSizes.Legal, - WordPageSize.Executive => PageSizes.Executive, - WordPageSize.A3 => PageSizes.A3, - WordPageSize.A4 => PageSizes.A4, - WordPageSize.A5 => PageSizes.A5, - WordPageSize.A6 => PageSizes.A6, - WordPageSize.B5 => PageSizes.B5, - _ => PageSizes.A4 - }; - } - } -} diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs new file mode 100644 index 000000000..edcc2afc4 --- /dev/null +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -0,0 +1,5484 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using OfficeIMO.Drawing; +using A = DocumentFormat.OpenXml.Drawing; +using W = DocumentFormat.OpenXml.Wordprocessing; +using W14 = DocumentFormat.OpenXml.Office2010.Word; +using W15 = DocumentFormat.OpenXml.Office2013.Word; +using Wps = DocumentFormat.OpenXml.Office2010.Word.DrawingShape; +using PdfCore = OfficeIMO.Pdf; + +namespace OfficeIMO.Word.Pdf { + public static partial class WordPdfConverterExtensions { + private const double NativeDefaultParagraphLineHeight = 1.15D; + private const double NativeDefaultParagraphSpacingAfter = 8D; + + private interface INativePdfFlow { + void PageBreak(); + void Bookmark(string name); + void HR(double? thickness = null, PdfCore.PdfColor? color = null, double? spacingBefore = null, double? spacingAfter = null, PdfCore.PdfHorizontalRuleStyle? style = null); + void Paragraph(Action build, PdfCore.PdfAlign align = PdfCore.PdfAlign.Left, PdfCore.PdfColor? defaultColor = null, PdfCore.PdfParagraphStyle? style = null); + void PanelParagraph(Action build, PdfCore.PanelStyle? style = null, PdfCore.PdfAlign align = PdfCore.PdfAlign.Left, PdfCore.PdfColor? defaultColor = null); + void Heading(int level, string text, PdfCore.PdfAlign align, PdfCore.PdfColor? color, PdfCore.PdfHeadingStyle? style, string? linkUri, string? linkDestinationName, string? linkContents); + void RichNumbered(IEnumerable items, PdfCore.PdfAlign align, PdfCore.PdfColor? color, int startNumber, PdfCore.PdfListStyle? style); + void RichBullets(IEnumerable items, PdfCore.PdfAlign align, PdfCore.PdfColor? color, PdfCore.PdfListStyle? style); + void TextField(string name, double width, double height, string value, PdfCore.PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, PdfCore.PdfFormFieldStyle? style = null); + void ChoiceField(string name, IEnumerable options, string? value, double width, double height, PdfCore.PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, bool isComboBox, PdfCore.PdfFormFieldStyle? style = null); + void CheckBox(string name, bool isChecked, double size, PdfCore.PdfAlign align, double spacingBefore, double spacingAfter, string checkedValueName = "Yes", PdfCore.PdfFormFieldStyle? style = null); + void Shape(OfficeShape shape, PdfCore.PdfAlign? align = null, double? spacingBefore = null, double? spacingAfter = null, PdfCore.PdfDrawingStyle? style = null, string? linkUri = null, string? linkContents = null); + void Table(IEnumerable rows, PdfCore.PdfAlign align, PdfCore.PdfTableStyle? style); + void Image(byte[] bytes, double width, double height, PdfCore.PdfAlign? align = null); + } + + private sealed class NativePdfDocFlow : INativePdfFlow { + private readonly PdfCore.PdfDoc _pdf; + + public NativePdfDocFlow(PdfCore.PdfDoc pdf) { + _pdf = pdf; + } + + public void PageBreak() => _pdf.PageBreak(); + public void Bookmark(string name) => _pdf.Bookmark(name); + public void HR(double? thickness = null, PdfCore.PdfColor? color = null, double? spacingBefore = null, double? spacingAfter = null, PdfCore.PdfHorizontalRuleStyle? style = null) => _pdf.HR(thickness, color, spacingBefore, spacingAfter, style); + public void Paragraph(Action build, PdfCore.PdfAlign align = PdfCore.PdfAlign.Left, PdfCore.PdfColor? defaultColor = null, PdfCore.PdfParagraphStyle? style = null) => _pdf.Paragraph(build, align, defaultColor, style); + public void PanelParagraph(Action build, PdfCore.PanelStyle? style = null, PdfCore.PdfAlign align = PdfCore.PdfAlign.Left, PdfCore.PdfColor? defaultColor = null) => _pdf.PanelParagraph(build, style, align, defaultColor); + public void Heading(int level, string text, PdfCore.PdfAlign align, PdfCore.PdfColor? color, PdfCore.PdfHeadingStyle? style, string? linkUri, string? linkDestinationName, string? linkContents) { + if (level == 1) _pdf.H1(text, align, color, linkUri: linkUri, style: style, linkContents: linkContents, linkDestinationName: linkDestinationName); + else if (level == 2) _pdf.H2(text, align, color, linkUri: linkUri, style: style, linkContents: linkContents, linkDestinationName: linkDestinationName); + else _pdf.H3(text, align, color, linkUri: linkUri, style: style, linkContents: linkContents, linkDestinationName: linkDestinationName); + } + public void RichNumbered(IEnumerable items, PdfCore.PdfAlign align, PdfCore.PdfColor? color, int startNumber, PdfCore.PdfListStyle? style) => _pdf.RichNumbered(items, align, color, startNumber, style); + public void RichBullets(IEnumerable items, PdfCore.PdfAlign align, PdfCore.PdfColor? color, PdfCore.PdfListStyle? style) => _pdf.RichBullets(items, align, color, style); + public void TextField(string name, double width, double height, string value, PdfCore.PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, PdfCore.PdfFormFieldStyle? style) => _pdf.TextField(name, width, height, value, align, fontSize, spacingBefore, spacingAfter, style); + public void ChoiceField(string name, IEnumerable options, string? value, double width, double height, PdfCore.PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, bool isComboBox, PdfCore.PdfFormFieldStyle? style) => _pdf.ChoiceField(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox, style); + public void CheckBox(string name, bool isChecked, double size, PdfCore.PdfAlign align, double spacingBefore, double spacingAfter, string checkedValueName, PdfCore.PdfFormFieldStyle? style) => _pdf.CheckBox(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName, style); + public void Shape(OfficeShape shape, PdfCore.PdfAlign? align = null, double? spacingBefore = null, double? spacingAfter = null, PdfCore.PdfDrawingStyle? style = null, string? linkUri = null, string? linkContents = null) => _pdf.Shape(shape, align, spacingBefore, spacingAfter, style, linkUri, linkContents); + public void Table(IEnumerable rows, PdfCore.PdfAlign align, PdfCore.PdfTableStyle? style) => _pdf.Table(rows, align, style); + public void Image(byte[] bytes, double width, double height, PdfCore.PdfAlign? align = null) => _pdf.Image(bytes, width, height, align); + } + + private sealed class NativePdfColumnFlow : INativePdfFlow { + private readonly PdfCore.PdfRowColumnCompose _column; + + public NativePdfColumnFlow(PdfCore.PdfRowColumnCompose column) { + _column = column; + } + + public void PageBreak() => _column.PageBreak(); + public void Bookmark(string name) => _column.Bookmark(name); + public void HR(double? thickness = null, PdfCore.PdfColor? color = null, double? spacingBefore = null, double? spacingAfter = null, PdfCore.PdfHorizontalRuleStyle? style = null) => _column.HR(thickness, color, spacingBefore, spacingAfter, style); + public void Paragraph(Action build, PdfCore.PdfAlign align = PdfCore.PdfAlign.Left, PdfCore.PdfColor? defaultColor = null, PdfCore.PdfParagraphStyle? style = null) => _column.Paragraph(build, align, defaultColor, style); + public void PanelParagraph(Action build, PdfCore.PanelStyle? style = null, PdfCore.PdfAlign align = PdfCore.PdfAlign.Left, PdfCore.PdfColor? defaultColor = null) => _column.PanelParagraph(build, style, align, defaultColor); + public void Heading(int level, string text, PdfCore.PdfAlign align, PdfCore.PdfColor? color, PdfCore.PdfHeadingStyle? style, string? linkUri, string? linkDestinationName, string? linkContents) { + if (level == 1) _column.H1(text, align, color, linkUri: linkUri, style: style, linkContents: linkContents, linkDestinationName: linkDestinationName); + else if (level == 2) _column.H2(text, align, color, linkUri: linkUri, style: style, linkContents: linkContents, linkDestinationName: linkDestinationName); + else _column.H3(text, align, color, linkUri: linkUri, style: style, linkContents: linkContents, linkDestinationName: linkDestinationName); + } + public void RichNumbered(IEnumerable items, PdfCore.PdfAlign align, PdfCore.PdfColor? color, int startNumber, PdfCore.PdfListStyle? style) => _column.RichNumbered(items, align, color, startNumber, style); + public void RichBullets(IEnumerable items, PdfCore.PdfAlign align, PdfCore.PdfColor? color, PdfCore.PdfListStyle? style) => _column.RichBullets(items, align, color, style); + public void TextField(string name, double width, double height, string value, PdfCore.PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, PdfCore.PdfFormFieldStyle? style) => _column.TextField(name, width, height, value, align, fontSize, spacingBefore, spacingAfter, style); + public void ChoiceField(string name, IEnumerable options, string? value, double width, double height, PdfCore.PdfAlign align, double fontSize, double spacingBefore, double spacingAfter, bool isComboBox, PdfCore.PdfFormFieldStyle? style) => _column.ChoiceField(name, options, value, width, height, align, fontSize, spacingBefore, spacingAfter, isComboBox, style); + public void CheckBox(string name, bool isChecked, double size, PdfCore.PdfAlign align, double spacingBefore, double spacingAfter, string checkedValueName, PdfCore.PdfFormFieldStyle? style) => _column.CheckBox(name, isChecked, size, align, spacingBefore, spacingAfter, checkedValueName, style); + public void Shape(OfficeShape shape, PdfCore.PdfAlign? align = null, double? spacingBefore = null, double? spacingAfter = null, PdfCore.PdfDrawingStyle? style = null, string? linkUri = null, string? linkContents = null) => _column.Shape(shape, align, spacingBefore, spacingAfter, style, linkUri, linkContents); + public void Table(IEnumerable rows, PdfCore.PdfAlign align, PdfCore.PdfTableStyle? style) => _column.Table(rows, align, style); + public void Image(byte[] bytes, double width, double height, PdfCore.PdfAlign? align = null) => _column.Image(bytes, width, height, align); + } + + private static PdfCore.PdfDoc CreateOfficeIMOPdfDocument(WordDocument document, PdfSaveOptions? options) { + options?.Warnings.Clear(); + + BuiltinDocumentProperties properties = document.BuiltinDocumentProperties; + PdfCore.PdfDoc pdf = PdfCore.PdfDoc.Create(CreateNativeOptions(document, options)) + .Meta( + title: options?.Title ?? properties.Title, + author: options?.Author ?? properties.Creator, + subject: options?.Subject ?? properties.Subject, + keywords: BuildNativeKeywords(options, properties)); + + Dictionary listMarkers = DocumentTraversal.BuildListMarkers(document); + Dictionary listIndices = DocumentTraversal.BuildListIndices(document); + Dictionary headingDestinations = BuildNativeHeadingDestinations(document); + IReadOnlyList tableOfContentsEntries = BuildNativeTableOfContentsEntries(document, options, headingDestinations); + foreach (WordSection section in document.Sections) { + IReadOnlyList elements = CollapseNativeParagraphElements(section.Elements); + List footnotes = CollectNativeFootnotes(elements, out Dictionary footnoteNumbersById); + pdf.Section(page => { + page.Size(GetNativePageSize(section, options)); + page.Margin(GetNativeMargins(section, options)); + ConfigureNativePageNumbering(page, section); + ConfigureNativeHeaderFooter(page, section, options); + var flow = new NativePdfDocFlow(pdf); + + if (TryRenderNativeSectionColumns( + page, + section, + elements, + listMarkers, + listIndices, + footnoteNumbersById, + options, + tableOfContentsEntries, + headingDestinations)) { + RenderNativeFootnotes(flow, footnotes); + return; + } + + for (int i = 0; i < elements.Count; i++) { + WordElement element = elements[i]; + if (element is WordFootNote) { + continue; + } + + if (TryRenderNativeList( + flow, + elements, + ref i, + listMarkers, + listIndices, + footnoteNumbersById)) { + continue; + } + + RenderNativeElement( + flow, + element, + paragraph => listMarkers.TryGetValue(paragraph, out var marker) ? marker : null, + GetNativeFootnoteNumbersForElement(elements, i, footnoteNumbersById), + footnoteNumbersById, + options, + tableOfContentsEntries, + headingDestinations); + } + + RenderNativeFootnotes(flow, footnotes); + }); + } + + return pdf; + } + + private static IReadOnlyList CollapseNativeParagraphElements(IEnumerable elements) { + var collapsed = new List(); + var paragraphIndexes = new Dictionary(); + + foreach (WordElement element in elements) { + if (element is WordParagraph paragraph && paragraph._paragraph != null) { + if (paragraphIndexes.TryGetValue(paragraph._paragraph, out int existingIndex)) { + if (ShouldReplaceNativeParagraphElement(collapsed[existingIndex], paragraph)) { + collapsed[existingIndex] = paragraph; + } + + continue; + } + + paragraphIndexes.Add(paragraph._paragraph, collapsed.Count); + } + + collapsed.Add(element); + } + + return collapsed; + } + + private static bool ShouldReplaceNativeParagraphElement(WordElement existing, WordParagraph candidate) { + if (existing is not WordParagraph existingParagraph) { + return false; + } + + if (string.IsNullOrEmpty(existingParagraph.Bookmark?.Name) && + !string.IsNullOrEmpty(candidate.Bookmark?.Name)) { + return true; + } + + return false; + } + + private static bool TryRenderNativeSectionColumns( + PdfCore.PdfPageCompose page, + WordSection section, + IReadOnlyList elements, + Dictionary listMarkers, + Dictionary listIndices, + Dictionary footnoteNumbersById, + PdfSaveOptions? options, + IReadOnlyList tableOfContentsEntries, + IReadOnlyDictionary headingDestinations) { + IReadOnlyList columnWidthPercents = GetNativeSectionColumnWidthPercents(section); + int columnCount = columnWidthPercents.Count; + if (columnCount <= 1) { + return false; + } + + IReadOnlyList> columns = SplitNativeElementsByColumnBreaks(elements, columnCount); + double gap = GetNativeSectionColumnGap(section); + page.Content(content => content.Row(row => { + row.Gap(gap); + if (section.HasColumnSeparator) { + row.ColumnSeparator(PdfCore.PdfColor.Black, 0.5D); + } + + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) { + IReadOnlyList columnElements = columns[columnIndex]; + row.Column(columnWidthPercents[columnIndex], column => { + var flow = new NativePdfColumnFlow(column); + bool hasContent = false; + for (int i = 0; i < columnElements.Count; i++) { + WordElement element = columnElements[i]; + if (element is WordFootNote) { + continue; + } + + if (TryRenderNativeList( + flow, + columnElements, + ref i, + listMarkers, + listIndices, + footnoteNumbersById)) { + hasContent = true; + continue; + } + + RenderNativeElement( + flow, + element, + paragraph => listMarkers.TryGetValue(paragraph, out var marker) ? marker : null, + GetNativeFootnoteNumbersForElement(columnElements, i, footnoteNumbersById), + footnoteNumbersById, + options, + tableOfContentsEntries, + headingDestinations); + hasContent = true; + } + + if (!hasContent) { + column.Spacer(0); + } + }); + } + })); + return true; + } + + private static int GetNativeSectionColumnCount(WordSection section) { + int? explicitColumnCount = section._sectionProperties + .GetFirstChild()? + .Elements() + .Count(); + int count = section.ColumnCount ?? explicitColumnCount ?? 1; + if (count < 1) { + return 1; + } + + return Math.Min(count, 8); + } + + private static IReadOnlyList GetNativeSectionColumnWidthPercents(WordSection section) { + int columnCount = GetNativeSectionColumnCount(section); + if (columnCount <= 1) { + return new[] { 100D }; + } + + List? explicitWidths = GetNativeExplicitSectionColumnWidths(section, columnCount); + if (explicitWidths == null || explicitWidths.Count == 0) { + return CreateEqualNativeColumnWidths(columnCount); + } + + int total = explicitWidths.Sum(); + if (total <= 0) { + return CreateEqualNativeColumnWidths(columnCount); + } + + var widths = new List(explicitWidths.Count); + double accumulated = 0D; + for (int i = 0; i < explicitWidths.Count; i++) { + double percent = i == explicitWidths.Count - 1 + ? 100D - accumulated + : explicitWidths[i] * 100D / total; + widths.Add(percent); + accumulated += percent; + } + + return widths; + } + + private static List CreateEqualNativeColumnWidths(int columnCount) { + var widths = new List(columnCount); + for (int i = 0; i < columnCount; i++) { + widths.Add(100D / columnCount); + } + + return widths; + } + + private static List? GetNativeExplicitSectionColumnWidths(WordSection section, int columnCount) { + W.Columns? columns = section._sectionProperties.GetFirstChild(); + if (columns == null) { + return null; + } + + var widths = new List(columnCount); + foreach (W.Column column in columns.Elements().Take(columnCount)) { + if (!TryParseNativeTwips(column.Width?.Value, out int width) || width <= 0) { + return null; + } + + widths.Add(width); + } + + return widths.Count == columnCount ? widths : null; + } + + private static double GetNativeSectionColumnGap(WordSection section) { + double? gap = section.ColumnsSpace.HasValue ? ConvertNativeTwipsToPoints(section.ColumnsSpace.Value) : null; + if (!gap.HasValue) { + W.Column? firstColumn = section._sectionProperties.GetFirstChild()?.Elements().FirstOrDefault(); + if (TryParseNativeTwips(firstColumn?.Space?.Value, out int columnGap)) { + gap = ConvertNativeTwipsToPoints(columnGap); + } + } + + if (!gap.HasValue || gap.Value < 0D) { + return 36D; + } + + return gap.Value; + } + + private static bool TryParseNativeTwips(string? value, out int twips) { + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out twips); + } + + private static IReadOnlyList> SplitNativeElementsByColumnBreaks(IReadOnlyList elements, int columnCount) { + var columns = new List>(columnCount); + for (int i = 0; i < columnCount; i++) { + columns.Add(new List()); + } + + bool sawColumnBreak = false; + int currentColumn = 0; + foreach (WordElement element in elements) { + if (IsNativeColumnBreakElement(element)) { + sawColumnBreak = true; + AdvanceNativeColumn(columns, ref currentColumn); + continue; + } + + if (element is WordParagraph paragraph) { + if (TrySplitNativeParagraphAtColumnBreak(paragraph, out WordParagraph? beforeColumnBreak, out WordParagraph? afterColumnBreak)) { + sawColumnBreak = true; + if (beforeColumnBreak != null) { + columns[currentColumn].Add(beforeColumnBreak); + } + + AdvanceNativeColumn(columns, ref currentColumn); + + if (afterColumnBreak != null) { + columns[currentColumn].Add(afterColumnBreak); + } + + continue; + } + + NativeColumnBreakPlacement columnBreakPlacement = GetNativeParagraphColumnBreakPlacement(paragraph); + if (columnBreakPlacement != NativeColumnBreakPlacement.None) { + sawColumnBreak = true; + } + + if (columnBreakPlacement == NativeColumnBreakPlacement.StartsWithBreak) { + AdvanceNativeColumn(columns, ref currentColumn); + } + + columns[currentColumn].Add(element); + if (columnBreakPlacement == NativeColumnBreakPlacement.EndsWithBreak || + columnBreakPlacement == NativeColumnBreakPlacement.ContainsBreak) { + AdvanceNativeColumn(columns, ref currentColumn); + } + + continue; + } + + columns[currentColumn].Add(element); + } + + if (!sawColumnBreak) { + return SplitNativeElementsAcrossAutomaticColumns(elements, columnCount); + } + + return columns; + } + + private static bool TrySplitNativeParagraphAtColumnBreak(WordParagraph paragraph, out WordParagraph? before, out WordParagraph? after) { + before = null; + after = null; + if (paragraph._paragraph == null) { + return false; + } + + var beforeParagraph = new W.Paragraph(); + var afterParagraph = new W.Paragraph(); + if (paragraph._paragraph.ParagraphProperties != null) { + beforeParagraph.Append((W.ParagraphProperties)paragraph._paragraph.ParagraphProperties.CloneNode(true)); + afterParagraph.Append((W.ParagraphProperties)paragraph._paragraph.ParagraphProperties.CloneNode(true)); + } + + bool sawColumnBreak = false; + foreach (DocumentFormat.OpenXml.OpenXmlElement child in paragraph._paragraph.ChildElements) { + if (child is W.ParagraphProperties) { + continue; + } + + if (!sawColumnBreak && + TrySplitNativeOpenXmlAtColumnBreak(child, out DocumentFormat.OpenXml.OpenXmlElement? beforeChild, out DocumentFormat.OpenXml.OpenXmlElement? afterChild)) { + if (beforeChild != null) { + beforeParagraph.Append(beforeChild); + } + + if (afterChild != null) { + afterParagraph.Append(afterChild); + } + + sawColumnBreak = true; + continue; + } + + if (sawColumnBreak) { + afterParagraph.Append(child.CloneNode(true)); + } else { + beforeParagraph.Append(child.CloneNode(true)); + } + } + + if (!sawColumnBreak) { + return false; + } + + if (HasNativeRenderableOpenXmlContent(beforeParagraph)) { + before = new WordParagraph(paragraph._document, beforeParagraph); + } + + if (HasNativeRenderableOpenXmlContent(afterParagraph)) { + after = new WordParagraph(paragraph._document, afterParagraph); + } + + return true; + } + + private static bool TrySplitNativeOpenXmlAtColumnBreak( + DocumentFormat.OpenXml.OpenXmlElement element, + out DocumentFormat.OpenXml.OpenXmlElement? before, + out DocumentFormat.OpenXml.OpenXmlElement? after) { + before = null; + after = null; + if (IsNativeColumnBreakOpenXml(element)) { + return true; + } + + if (!element.HasChildren) { + return false; + } + + DocumentFormat.OpenXml.OpenXmlElement beforeElement = element.CloneNode(false); + DocumentFormat.OpenXml.OpenXmlElement afterElement = element.CloneNode(false); + bool sawColumnBreak = false; + bool containsColumnBreak = false; + foreach (DocumentFormat.OpenXml.OpenXmlElement child in element.ChildElements) { + if (!sawColumnBreak && + TrySplitNativeOpenXmlAtColumnBreak(child, out DocumentFormat.OpenXml.OpenXmlElement? beforeChild, out DocumentFormat.OpenXml.OpenXmlElement? afterChild)) { + containsColumnBreak = true; + if (beforeChild != null) { + beforeElement.Append(beforeChild); + } + + if (afterChild != null) { + afterElement.Append(afterChild); + } + + sawColumnBreak = true; + continue; + } + + if (sawColumnBreak) { + afterElement.Append(child.CloneNode(true)); + } else { + beforeElement.Append(child.CloneNode(true)); + } + } + + if (!containsColumnBreak) { + return false; + } + + if (HasNativeRenderableOpenXmlContent(beforeElement)) { + before = beforeElement; + } + + if (HasNativeRenderableOpenXmlContent(afterElement)) { + after = afterElement; + } + + return true; + } + + private static bool IsNativeColumnBreakOpenXml(DocumentFormat.OpenXml.OpenXmlElement element) => + element is W.Break wordBreak && wordBreak.Type?.Value == W.BreakValues.Column; + + private static bool HasNativeRenderableOpenXmlContent(DocumentFormat.OpenXml.OpenXmlElement element) { + if (element.Descendants().Any(text => !string.IsNullOrEmpty(text.Text)) || + element.Descendants().Any() || + element.Descendants().Any() || + element.Descendants().Any() || + element.Descendants().Any() || + element.Descendants().Any() || + element.Descendants().Any()) { + return true; + } + + return element.Descendants().Any(wordBreak => wordBreak.Type?.Value != W.BreakValues.Column); + } + + private static IReadOnlyList> SplitNativeElementsAcrossAutomaticColumns(IReadOnlyList elements, int columnCount) { + var columns = new List>(columnCount); + for (int i = 0; i < columnCount; i++) { + columns.Add(new List()); + } + + if (elements.Count == 0) { + return columns; + } + + int totalWeight = 0; + var weights = new int[elements.Count]; + for (int i = 0; i < elements.Count; i++) { + int weight = GetNativeAutomaticColumnWeight(elements[i]); + weights[i] = weight; + totalWeight += weight; + } + + int currentColumn = 0; + int currentWeight = 0; + for (int i = 0; i < elements.Count; i++) { + int remainingElements = elements.Count - i; + int remainingColumnsAfterCurrent = columnCount - currentColumn - 1; + if (currentColumn < columnCount - 1 && + columns[currentColumn].Count > 0) { + double targetWeight = (double)totalWeight * (currentColumn + 1) / columnCount; + if (remainingElements <= remainingColumnsAfterCurrent || + currentWeight >= targetWeight) { + if (!TryAdvanceNativeAutomaticColumnKeepingTrailingContent(columns, ref currentColumn)) { + currentColumn++; + } + } + } + + columns[currentColumn].Add(elements[i]); + currentWeight += weights[i]; + } + + return columns; + } + + private static bool TryAdvanceNativeAutomaticColumnKeepingTrailingContent(List> columns, ref int currentColumn) { + if (currentColumn >= columns.Count - 1) { + return false; + } + + List current = columns[currentColumn]; + if (current.Count <= 1) { + return false; + } + + int moveStart = current.Count - 1; + if (!ShouldKeepNativeElementWithFollowingContent(current[moveStart])) { + return false; + } + + while (moveStart > 0 && ShouldKeepNativeElementWithFollowingContent(current[moveStart - 1])) { + moveStart--; + } + + if (moveStart == 0) { + return false; + } + + List next = columns[currentColumn + 1]; + for (int i = moveStart; i < current.Count; i++) { + next.Add(current[i]); + } + + current.RemoveRange(moveStart, current.Count - moveStart); + currentColumn++; + return true; + } + + private static bool ShouldKeepNativeElementWithFollowingContent(WordElement element) => + element is WordParagraph paragraph && + (paragraph.KeepWithNext || GetHeadingLevel(paragraph) > 0); + + private static int GetNativeAutomaticColumnWeight(WordElement element) { + if (element is WordParagraph paragraph) { + return Math.Max(1, (paragraph.Text?.Length ?? 0) / 80 + 1); + } + + if (element is WordTable table) { + return Math.Max(2, table.Rows.Count * 2); + } + + return 1; + } + + private static void AdvanceNativeColumn(List> columns, ref int currentColumn) { + if (columns[currentColumn].Count > 0) { + currentColumn = Math.Min(columns.Count - 1, currentColumn + 1); + } + } + + private static bool IsNativeColumnBreakElement(WordElement element) { + if (element is WordBreak wordBreak) { + return wordBreak.BreakType == W.BreakValues.Column; + } + + return element is WordParagraph paragraph && + paragraph.Break?.BreakType == W.BreakValues.Column && + string.IsNullOrWhiteSpace(paragraph.Text); + } + + private enum NativeColumnBreakPlacement { + None, + StartsWithBreak, + EndsWithBreak, + ContainsBreak + } + + private static NativeColumnBreakPlacement GetNativeParagraphColumnBreakPlacement(WordParagraph paragraph) { + if (paragraph._paragraph == null) { + return NativeColumnBreakPlacement.None; + } + + bool sawColumnBreak = false; + bool hasContentBefore = false; + bool hasContentAfter = false; + InspectNativeColumnBreakFlow(paragraph._paragraph, ref sawColumnBreak, ref hasContentBefore, ref hasContentAfter); + if (!sawColumnBreak) { + return NativeColumnBreakPlacement.None; + } + + if (hasContentBefore && hasContentAfter) { + return NativeColumnBreakPlacement.ContainsBreak; + } + + if (hasContentBefore) { + return NativeColumnBreakPlacement.EndsWithBreak; + } + + if (hasContentAfter) { + return NativeColumnBreakPlacement.StartsWithBreak; + } + + return NativeColumnBreakPlacement.None; + } + + private static void InspectNativeColumnBreakFlow(DocumentFormat.OpenXml.OpenXmlElement element, ref bool sawColumnBreak, ref bool hasContentBefore, ref bool hasContentAfter) { + foreach (DocumentFormat.OpenXml.OpenXmlElement child in element.ChildElements) { + if (child is W.Break wordBreak && wordBreak.Type?.Value == W.BreakValues.Column) { + sawColumnBreak = true; + continue; + } + + if (child is W.Text text && !string.IsNullOrEmpty(text.Text)) { + if (sawColumnBreak) { + hasContentAfter = true; + } else { + hasContentBefore = true; + } + } else { + InspectNativeColumnBreakFlow(child, ref sawColumnBreak, ref hasContentBefore, ref hasContentAfter); + } + } + } + + private static bool TryRenderNativeList( + INativePdfFlow pdf, + IReadOnlyList elements, + ref int index, + Dictionary listMarkers, + Dictionary listIndices, + Dictionary footnoteNumbersById) { + if (elements[index] is not WordParagraph firstParagraph || + !TryGetNativeListItem(firstParagraph, listMarkers, listIndices, footnoteNumbersById, out bool ordered, out int level, out int startNumber, out PdfCore.PdfListItem? item, out PdfCore.PdfAlign align, out PdfCore.PdfColor? color, out PdfCore.PdfListStyle? style)) { + return false; + } + + var items = new List { item! }; + int nextIndex = index + 1; + int expectedNumber = startNumber + 1; + while (nextIndex < elements.Count && + elements[nextIndex] is WordParagraph paragraph && + TryGetNativeListItem(paragraph, listMarkers, listIndices, footnoteNumbersById, out bool nextOrdered, out int nextLevel, out int nextNumber, out PdfCore.PdfListItem? nextItem, out PdfCore.PdfAlign nextAlign, out PdfCore.PdfColor? nextColor, out PdfCore.PdfListStyle? nextStyle) && + nextOrdered == ordered && + nextLevel == level && + nextAlign == align && + nextColor.Equals(color) && + NativeListStylesEquivalent(nextStyle, style) && + (!ordered || nextNumber == expectedNumber)) { + items.Add(nextItem!); + nextIndex++; + expectedNumber++; + } + + if (ordered) { + pdf.RichNumbered(items, align, color, startNumber, style); + } else { + pdf.RichBullets(items, align, color, style); + } + + index = nextIndex - 1; + return true; + } + + private static bool TryGetNativeListItem( + WordParagraph paragraph, + Dictionary listMarkers, + Dictionary listIndices, + Dictionary footnoteNumbersById, + out bool ordered, + out int level, + out int index, + out PdfCore.PdfListItem? item, + out PdfCore.PdfAlign align, + out PdfCore.PdfColor? color, + out PdfCore.PdfListStyle? style) { + ordered = false; + level = 0; + index = 1; + item = null; + align = PdfCore.PdfAlign.Left; + color = null; + style = null; + + if (!listMarkers.TryGetValue(paragraph, out var marker) || + !listIndices.TryGetValue(paragraph, out var listIndex)) { + return false; + } + + DocumentTraversal.ListInfo? info = DocumentTraversal.GetListInfo(paragraph); + if (info == null || marker.Level != info.Value.Level || listIndex.Level != info.Value.Level) { + return false; + } + + if (paragraph.PageBreakBefore || + paragraph.IsPageBreak || + paragraph.Shape != null || + paragraph.TextBox != null || + paragraph.Image != null) { + return false; + } + + List runs = GetNativeRuns(paragraph); + if (runs.Any(run => run.IsImage)) { + return false; + } + + List richRuns = CreateNativeCellParagraphRuns(paragraph, footnoteNumbersById); + string content = string.Concat(richRuns.Select(run => run.Text)); + if (string.IsNullOrWhiteSpace(content)) { + return false; + } + + bool itemOrdered = info.Value.Ordered; + string displayMarker = itemOrdered + ? marker.Marker + : NormalizeNativeBulletMarker(marker.Marker); + ordered = itemOrdered; + level = info.Value.Level; + index = listIndex.Index; + item = new PdfCore.PdfListItem(richRuns, paragraph.Bookmark?.Name, string.IsNullOrWhiteSpace(displayMarker) ? null : displayMarker); + align = MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false); + color = ParseNativeColor(paragraph.ColorHex); + style = CreateNativeListStyle(paragraph, info.Value, displayMarker); + return true; + } + + private static string NormalizeNativeBulletMarker(string marker) { + if (string.IsNullOrWhiteSpace(marker)) { + return "•"; + } + + return marker.Trim() switch { + "\uf0b7" => "•", + "\u00b7" => "•", + "\u25cf" => "•", + "\u006f" => "o", + _ => marker + }; + } + + private static PdfCore.PdfListStyle CreateNativeListStyle(WordParagraph paragraph, DocumentTraversal.ListInfo info, string marker) { + const double defaultLevelTextIndent = 36D; + const double defaultHangingIndent = 18D; + + double textIndent = ConvertNativeTwipsToPoints(info.LeftIndentTwips ?? ((info.Level + 1) * 720)) ?? ((info.Level + 1) * defaultLevelTextIndent); + double hangingIndent = ConvertNativeTwipsToPoints(info.HangingIndentTwips ?? 360) ?? defaultHangingIndent; + double markerIndent = Math.Max(0D, textIndent - hangingIndent); + double fontSize = paragraph.FontSize.HasValue && paragraph.FontSize.Value > 0D ? paragraph.FontSize.Value : 11D; + double markerWidth = EstimateNativeListMarkerWidth(marker, fontSize); + double markerGap = Math.Max(0D, textIndent - markerIndent - markerWidth); + + var style = new PdfCore.PdfListStyle { + LeftIndent = markerIndent, + MarkerGap = markerGap + }; + + if (paragraph.FontSize.HasValue && paragraph.FontSize.Value > 0D) { + style.FontSize = paragraph.FontSize.Value; + } + + style.LineHeight = ResolveNativeParagraphLineHeight(paragraph, fontSize); + + if (paragraph.LineSpacingBeforePoints.HasValue) { + style.SpacingBefore = paragraph.LineSpacingBeforePoints.Value; + } + + if (paragraph.LineSpacingAfterPoints.HasValue) { + style.SpacingAfter = paragraph.LineSpacingAfterPoints.Value; + } + + style.KeepTogether = paragraph.KeepLinesTogether; + style.KeepWithNext = paragraph.KeepWithNext; + return style; + } + + private static double EstimateNativeListMarkerWidth(string marker, double fontSize) { + if (string.IsNullOrEmpty(marker)) { + return 0D; + } + + double width = 0D; + foreach (char ch in marker) { + if (char.IsDigit(ch) || char.IsLetter(ch)) { + width += fontSize * 0.56D; + } else if (char.IsWhiteSpace(ch)) { + width += fontSize * 0.28D; + } else if (ch == '.' || ch == ')' || ch == '(') { + width += fontSize * 0.28D; + } else if (ch == '\u2022' || ch == '\u25CF' || ch == '\u25E6') { + width += fontSize * 0.36D; + } else { + width += fontSize * 0.5D; + } + } + + return width; + } + + private static bool NativeListStylesEquivalent(PdfCore.PdfListStyle? left, PdfCore.PdfListStyle? right) { + if (ReferenceEquals(left, right)) { + return true; + } + + if (left == null || right == null) { + return false; + } + + return NullableDoubleEquals(left.FontSize, right.FontSize) && + NullableDoubleEquals(left.LineHeight, right.LineHeight) && + DoubleEquals(left.LeftIndent, right.LeftIndent) && + NullableDoubleEquals(left.MarkerGap, right.MarkerGap) && + DoubleEquals(left.SpacingBefore, right.SpacingBefore) && + NullableDoubleEquals(left.SpacingAfter, right.SpacingAfter) && + NullableDoubleEquals(left.ItemSpacing, right.ItemSpacing) && + left.Color.Equals(right.Color) && + left.KeepTogether == right.KeepTogether && + left.KeepWithNext == right.KeepWithNext; + } + + private static bool NullableDoubleEquals(double? left, double? right) { + if (left.HasValue != right.HasValue) { + return false; + } + + return !left.HasValue || DoubleEquals(left.Value, right!.Value); + } + + private static bool DoubleEquals(double left, double right) => + Math.Abs(left - right) < 0.001D; + + private static List GetNativeRuns(WordParagraph paragraph) { + if (paragraph._paragraph == null) { + return new List(); + } + + var runs = new List(); + foreach (var element in paragraph._paragraph.ChildElements) { + if (element is W.Run run) { + runs.Add(new WordParagraph(paragraph._document, paragraph._paragraph, run)); + } else if (element is W.Hyperlink hyperlink) { + AddNativeHyperlinkRuns(runs, paragraph, hyperlink); + } else if (element is W.SdtRun sdtRun && IsNativeSimpleTextContentControl(sdtRun)) { + foreach (var childElement in sdtRun.SdtContentRun!.ChildElements) { + if (childElement is W.Run sdtContentRun) { + runs.Add(new WordParagraph(paragraph._document, paragraph._paragraph, sdtContentRun)); + } else if (childElement is W.Hyperlink sdtHyperlink) { + AddNativeHyperlinkRuns(runs, paragraph, sdtHyperlink); + } + } + } + } + + return runs; + } + + private static void AddNativeHyperlinkRuns(List runs, WordParagraph paragraph, W.Hyperlink hyperlink) { + foreach (W.Run childRun in hyperlink.Elements()) { + var run = new WordParagraph(paragraph._document, paragraph._paragraph!, childRun) { _hyperlink = hyperlink }; + runs.Add(run); + } + } + + private static void ConfigureNativePageNumbering(PdfCore.PdfPageCompose page, WordSection section) { + W.PageNumberType? pageNumberType = section._sectionProperties.GetFirstChild(); + if (pageNumberType?.Start?.Value is int start && start > 0) { + page.PageNumberStart(start); + } + + PdfCore.PdfPageNumberStyle? style = MapNativePageNumberStyle(pageNumberType?.Format?.Value); + if (style.HasValue) { + page.PageNumberStyle(style.Value); + } + } + + private static PdfCore.PdfPageNumberStyle? MapNativePageNumberStyle(W.NumberFormatValues? format) { + if (format == W.NumberFormatValues.LowerRoman) { + return PdfCore.PdfPageNumberStyle.LowerRoman; + } + + if (format == W.NumberFormatValues.UpperRoman) { + return PdfCore.PdfPageNumberStyle.UpperRoman; + } + + if (format == W.NumberFormatValues.LowerLetter) { + return PdfCore.PdfPageNumberStyle.LowerLetter; + } + + if (format == W.NumberFormatValues.UpperLetter) { + return PdfCore.PdfPageNumberStyle.UpperLetter; + } + + if (format == W.NumberFormatValues.Decimal || format == W.NumberFormatValues.DecimalZero) { + return PdfCore.PdfPageNumberStyle.Arabic; + } + + return null; + } + + private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, WordSection section, PdfSaveOptions? options) { + RecordNativeHeaderFooterDiagnostics(section.Header?.Default, options, "default header"); + RecordNativeHeaderFooterDiagnostics(section.Header?.First, options, "first header"); + RecordNativeHeaderFooterDiagnostics(section.Header?.Even, options, "even header"); + RecordNativeHeaderFooterDiagnostics(section.Footer?.Default, options, "default footer"); + RecordNativeHeaderFooterDiagnostics(section.Footer?.First, options, "first footer"); + RecordNativeHeaderFooterDiagnostics(section.Footer?.Even, options, "even footer"); + + NativeHeaderFooterText? defaultHeader = GetNativeHeaderFooterText(section.Header?.Default); + NativeHeaderFooterText? firstHeader = GetNativeHeaderFooterText(section.Header?.First); + NativeHeaderFooterText? evenHeader = GetNativeHeaderFooterText(section.Header?.Even); + NativeHeaderFooterText? defaultFooter = GetNativeHeaderFooterText(section.Footer?.Default); + NativeHeaderFooterText? firstFooter = GetNativeHeaderFooterText(section.Footer?.First); + NativeHeaderFooterText? evenFooter = GetNativeHeaderFooterText(section.Footer?.Even); + IReadOnlyList defaultHeaderImages = GetNativeHeaderFooterImages(section.Header?.Default); + IReadOnlyList firstHeaderImages = GetNativeHeaderFooterImages(section.Header?.First); + IReadOnlyList evenHeaderImages = GetNativeHeaderFooterImages(section.Header?.Even); + IReadOnlyList defaultFooterImages = GetNativeHeaderFooterImages(section.Footer?.Default); + IReadOnlyList firstFooterImages = GetNativeHeaderFooterImages(section.Footer?.First); + IReadOnlyList evenFooterImages = GetNativeHeaderFooterImages(section.Footer?.Even); + IReadOnlyList defaultHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.Default); + IReadOnlyList firstHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.First); + IReadOnlyList evenHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.Even); + IReadOnlyList defaultFooterShapes = GetNativeHeaderFooterShapes(section.Footer?.Default); + IReadOnlyList firstFooterShapes = GetNativeHeaderFooterShapes(section.Footer?.First); + IReadOnlyList evenFooterShapes = GetNativeHeaderFooterShapes(section.Footer?.Even); + ApplyNativeHeaderFooterPageNumberStyle(page, defaultHeader, firstHeader, evenHeader, defaultFooter, firstFooter, evenFooter); + if (defaultHeader != null || firstHeader != null || evenHeader != null || + defaultHeaderImages.Count > 0 || firstHeaderImages.Count > 0 || evenHeaderImages.Count > 0 || + defaultHeaderShapes.Count > 0 || firstHeaderShapes.Count > 0 || evenHeaderShapes.Count > 0) { + page.Header(header => { + if (defaultHeader != null) { + header.Zones(defaultHeader.Left, defaultHeader.Center, defaultHeader.Right); + } + + AddNativeHeaderImages(header, defaultHeaderImages, W.HeaderFooterValues.Default); + AddNativeHeaderShapes(header, defaultHeaderShapes, W.HeaderFooterValues.Default); + + if (firstHeader != null) { + header.FirstPageZones(firstHeader.Left, firstHeader.Center, firstHeader.Right); + } + + AddNativeHeaderImages(header, firstHeaderImages, W.HeaderFooterValues.First); + AddNativeHeaderShapes(header, firstHeaderShapes, W.HeaderFooterValues.First); + + if (evenHeader != null) { + header.EvenPagesZones(evenHeader.Left, evenHeader.Center, evenHeader.Right); + } + + AddNativeHeaderImages(header, evenHeaderImages, W.HeaderFooterValues.Even); + AddNativeHeaderShapes(header, evenHeaderShapes, W.HeaderFooterValues.Even); + }); + } + + bool includePageNumbers = options?.IncludePageNumbers ?? true; + if (!includePageNumbers && defaultFooter == null && firstFooter == null && evenFooter == null && + defaultFooterImages.Count == 0 && firstFooterImages.Count == 0 && evenFooterImages.Count == 0 && + defaultFooterShapes.Count == 0 && firstFooterShapes.Count == 0 && evenFooterShapes.Count == 0) { + return; + } + + string pageNumberFormat = GetNativePageNumberFormat(options); + page.Footer(footer => { + NativeHeaderFooterText? resolvedDefaultFooter = WithNativeFooterPageNumber(defaultFooter, includePageNumbers, pageNumberFormat); + if (resolvedDefaultFooter != null) { + footer.Zones(resolvedDefaultFooter.Left, resolvedDefaultFooter.Center, resolvedDefaultFooter.Right); + } + + AddNativeFooterImages(footer, defaultFooterImages, W.HeaderFooterValues.Default); + AddNativeFooterShapes(footer, defaultFooterShapes, W.HeaderFooterValues.Default); + + NativeHeaderFooterText? resolvedFirstFooter = WithNativeFooterPageNumber(firstFooter, includePageNumbers && firstFooter != null, pageNumberFormat); + if (resolvedFirstFooter != null) { + footer.FirstPageZones(resolvedFirstFooter.Left, resolvedFirstFooter.Center, resolvedFirstFooter.Right); + } + + AddNativeFooterImages(footer, firstFooterImages, W.HeaderFooterValues.First); + AddNativeFooterShapes(footer, firstFooterShapes, W.HeaderFooterValues.First); + + NativeHeaderFooterText? resolvedEvenFooter = WithNativeFooterPageNumber(evenFooter, includePageNumbers && evenFooter != null, pageNumberFormat); + if (resolvedEvenFooter != null) { + footer.EvenPagesZones(resolvedEvenFooter.Left, resolvedEvenFooter.Center, resolvedEvenFooter.Right); + } + + AddNativeFooterImages(footer, evenFooterImages, W.HeaderFooterValues.Even); + AddNativeFooterShapes(footer, evenFooterShapes, W.HeaderFooterValues.Even); + }); + } + + private static void AddNativeHeaderImages(PdfCore.PdfHeaderCompose header, IReadOnlyList images, W.HeaderFooterValues variant) { + foreach (NativeHeaderFooterImage image in images) { + if (variant == W.HeaderFooterValues.First) { + header.FirstPageImage(image.Data, image.Width, image.Height, image.Align); + } else if (variant == W.HeaderFooterValues.Even) { + header.EvenPagesImage(image.Data, image.Width, image.Height, image.Align); + } else { + header.Image(image.Data, image.Width, image.Height, image.Align); + } + } + } + + private static void AddNativeHeaderShapes(PdfCore.PdfHeaderCompose header, IReadOnlyList shapes, W.HeaderFooterValues variant) { + foreach (NativeHeaderFooterShape shape in shapes) { + if (variant == W.HeaderFooterValues.First) { + header.FirstPageShape(shape.Shape, shape.Align); + } else if (variant == W.HeaderFooterValues.Even) { + header.EvenPagesShape(shape.Shape, shape.Align); + } else { + header.Shape(shape.Shape, shape.Align); + } + } + } + + private static void AddNativeFooterImages(PdfCore.PdfFooterCompose footer, IReadOnlyList images, W.HeaderFooterValues variant) { + foreach (NativeHeaderFooterImage image in images) { + if (variant == W.HeaderFooterValues.First) { + footer.FirstPageImage(image.Data, image.Width, image.Height, image.Align); + } else if (variant == W.HeaderFooterValues.Even) { + footer.EvenPagesImage(image.Data, image.Width, image.Height, image.Align); + } else { + footer.Image(image.Data, image.Width, image.Height, image.Align); + } + } + } + + private static void AddNativeFooterShapes(PdfCore.PdfFooterCompose footer, IReadOnlyList shapes, W.HeaderFooterValues variant) { + foreach (NativeHeaderFooterShape shape in shapes) { + if (variant == W.HeaderFooterValues.First) { + footer.FirstPageShape(shape.Shape, shape.Align); + } else if (variant == W.HeaderFooterValues.Even) { + footer.EvenPagesShape(shape.Shape, shape.Align); + } else { + footer.Shape(shape.Shape, shape.Align); + } + } + } + + private static void RecordNativeHeaderFooterDiagnostics(WordHeaderFooter? headerFooter, PdfSaveOptions? options, string source) { + if (headerFooter == null || options == null) { + return; + } + + foreach (WordElement element in headerFooter.Elements) { + RecordNativeHeaderFooterElementDiagnostics(element, options, source); + } + } + + private static void RecordNativeHeaderFooterElementDiagnostics(WordElement element, PdfSaveOptions options, string source) { + switch (element) { + case WordParagraph paragraph: + RecordNativeHeaderFooterParagraphDiagnostics(paragraph, options, source); + break; + case WordTable table: + RecordNativeHeaderFooterTableDiagnostics(table, options, source + " table"); + break; + case WordEmbeddedDocument: + AddNativeExportWarning( + options, + "NativeHeaderFooterEmbeddedDocumentUnsupported", + source, + "Embedded documents in Word headers and footers are not mapped by the OfficeIMO PDF engine yet."); + break; + } + } + + private static void RecordNativeHeaderFooterTableDiagnostics(WordTable table, PdfSaveOptions options, string source) { + foreach (WordTableRow row in table.Rows) { + foreach (WordTableCell cell in row.Cells) { + foreach (WordElement element in cell.Elements) { + RecordNativeHeaderFooterElementDiagnostics(element, options, source); + } + } + } + } + + private static void RecordNativeHeaderFooterParagraphDiagnostics(WordParagraph paragraph, PdfSaveOptions options, string source) { + if (paragraph.Shape != null && CreateNativeShape(paragraph.Shape) == null) { + AddNativeExportWarning( + options, + "NativeHeaderFooterShapeUnsupported", + source, + "Word header and footer shapes without supported geometry are not mapped by the OfficeIMO PDF engine yet."); + } + + if (HasNativeUnsupportedHeaderFooterTextBox(paragraph)) { + AddNativeExportWarning( + options, + "NativeHeaderFooterTextBoxUnsupported", + source, + "Word header and footer text boxes without extractable text are not mapped by the OfficeIMO PDF engine yet."); + } + + if (paragraph.IsSmartArt) { + AddNativeExportWarning( + options, + "NativeHeaderFooterSmartArtUnsupported", + source, + "SmartArt in Word headers and footers is not mapped by the OfficeIMO PDF engine yet."); + } + + if (paragraph.IsEquation && string.IsNullOrWhiteSpace(GetNativeEquationText(paragraph))) { + AddNativeExportWarning( + options, + "NativeHeaderFooterEquationUnsupported", + source, + "Equations in Word headers and footers are not mapped by the OfficeIMO PDF engine yet."); + } + + if (HasNativeUnsupportedHeaderFooterContentControl(paragraph)) { + AddNativeExportWarning( + options, + "NativeHeaderFooterContentControlUnsupported", + source, + "Content controls in Word headers and footers are not mapped by the OfficeIMO PDF engine yet."); + } + } + + private static bool HasNativeUnsupportedHeaderFooterTextBox(WordParagraph paragraph) => + paragraph.TextBox != null && + string.IsNullOrWhiteSpace(GetNativeParagraphTextBoxPlainText(paragraph)); + + private static void RecordNativeBodyTableDiagnostics(WordTable table, PdfSaveOptions? options, string source) { + if (options == null) { + return; + } + + foreach (WordTableRow row in table.Rows) { + foreach (WordTableCell cell in row.Cells) { + foreach (WordParagraph paragraph in cell.Paragraphs) { + RecordNativeBodyParagraphDiagnostics(paragraph, options, source, mapsCheckBoxes: true, mapsFormFields: true, mapsPictureControls: true, mapsRepeatingSections: true); + } + + foreach (WordElement element in cell.Elements) { + if (element is not WordParagraph) { + RecordNativeBodyElementDiagnostics(element, options, source); + } + } + } + } + } + + private static void RecordNativeBodyElementDiagnostics(WordElement element, PdfSaveOptions options, string source) { + switch (element) { + case WordParagraph paragraph: + RecordNativeBodyParagraphDiagnostics(paragraph, options, source, mapsCheckBoxes: false, mapsFormFields: false, mapsPictureControls: false, mapsRepeatingSections: false); + break; + case WordTable table: + RecordNativeBodyTableDiagnostics(table, options, source + " table"); + break; + case WordEmbeddedDocument: + AddNativeExportWarning( + options, + "NativeBodyEmbeddedDocumentUnsupported", + source, + "Embedded documents in Word body content are not mapped by the OfficeIMO PDF engine yet."); + break; + } + } + + private static void RecordNativeBodyParagraphDiagnostics(WordParagraph paragraph, PdfSaveOptions? options, string source, bool mapsCheckBoxes, bool mapsFormFields, bool mapsPictureControls, bool mapsRepeatingSections) { + if (options == null) { + return; + } + + if (paragraph.IsSmartArt) { + AddNativeExportWarning( + options, + "NativeBodySmartArtUnsupported", + source, + "SmartArt in Word body content is not mapped by the OfficeIMO PDF engine yet."); + } + + if (paragraph.IsEquation && string.IsNullOrWhiteSpace(GetNativeEquationText(paragraph))) { + AddNativeExportWarning( + options, + "NativeBodyEquationUnsupported", + source, + "Equations in Word body content are not mapped by the OfficeIMO PDF engine yet."); + } + + if (HasNativeUnsupportedBodyContentControl(paragraph, mapsCheckBoxes, mapsFormFields, mapsPictureControls, mapsRepeatingSections)) { + AddNativeExportWarning( + options, + "NativeBodyContentControlUnsupported", + source, + "Content controls in Word body content are not mapped by the OfficeIMO PDF engine yet."); + } + } + + private static bool HasNativeUnsupportedHeaderFooterContentControl(WordParagraph paragraph) => + (paragraph.IsCheckBox && GetNativeCheckBoxControls(paragraph).Count == 0) || + ((paragraph.IsDatePicker || paragraph.IsDropDownList || paragraph.IsComboBox) && GetNativeFormFieldControls(paragraph).Count == 0) || + (paragraph.IsPictureControl && paragraph.PictureControl?.Image == null) || + (paragraph.IsRepeatingSection && paragraph.RepeatingSection?.TextItems.Count == 0) || + paragraph._paragraph?.Descendants().Any(sdtRun => + !IsNativeSimpleTextContentControl(sdtRun) && + !IsNativeCheckBoxControl(sdtRun) && + !IsNativeSupportedFormFieldContentControl(sdtRun) && + !IsNativePictureControlWithImage(paragraph, sdtRun) && + !IsNativeRepeatingSectionWithText(sdtRun) && + !IsNativeRepeatingSectionChildControl(sdtRun)) == true || + paragraph._paragraph?.Descendants().Any() == true || + paragraph._paragraph?.Descendants().Any() == true; + + private static bool HasNativeUnsupportedBodyContentControl(WordParagraph paragraph, bool mapsCheckBoxes, bool mapsFormFields, bool mapsPictureControls, bool mapsRepeatingSections) => + (!mapsCheckBoxes && paragraph.IsCheckBox) || + (!mapsFormFields && (paragraph.IsDatePicker || paragraph.IsDropDownList || paragraph.IsComboBox)) || + (paragraph.IsPictureControl && (!mapsPictureControls || paragraph.PictureControl?.Image == null)) || + (paragraph.IsRepeatingSection && (!mapsRepeatingSections || paragraph.RepeatingSection?.TextItems.Count == 0)) || + paragraph._paragraph?.Descendants().Any(sdtRun => + (!mapsCheckBoxes || !IsNativeCheckBoxControl(sdtRun)) && + (!mapsFormFields || !IsNativeSupportedFormFieldContentControl(sdtRun)) && + (!mapsPictureControls || !IsNativePictureControl(sdtRun)) && + (!mapsRepeatingSections || !IsNativeRepeatingSectionControl(sdtRun) && !IsNativeRepeatingSectionChildControl(sdtRun)) && + !IsNativeSimpleTextContentControl(sdtRun)) == true || + paragraph._paragraph?.Descendants().Any() == true || + paragraph._paragraph?.Descendants().Any() == true; + + private static IReadOnlyList GetNativeCheckBoxControls(WordParagraph paragraph) { + if (paragraph._paragraph == null) { + return Array.Empty(); + } + + return paragraph._paragraph.Descendants() + .Where(IsNativeCheckBoxControl) + .ToList(); + } + + private static bool IsNativeCheckBoxControl(W.SdtRun sdtRun) => + sdtRun.SdtProperties?.Elements().Any() == true; + + private static bool IsNativePictureControl(W.SdtRun sdtRun) => + sdtRun.SdtProperties?.Elements().Any() == true; + + private static bool IsNativePictureControlWithImage(WordParagraph paragraph, W.SdtRun sdtRun) { + if (!IsNativePictureControl(sdtRun) || paragraph._paragraph == null) { + return false; + } + + var pictureParagraph = new WordParagraph(paragraph._document, paragraph._paragraph, sdtRun); + return pictureParagraph.PictureControl?.Image != null; + } + + private static IReadOnlyList GetNativePictureControls(WordParagraph paragraph) { + if (paragraph._paragraph == null) { + return Array.Empty(); + } + + return paragraph._paragraph.Descendants() + .Where(IsNativePictureControl) + .ToList(); + } + + private static bool IsNativeRepeatingSectionControl(W.SdtRun sdtRun) => + sdtRun.SdtProperties?.Elements().Any() == true; + + private static bool IsNativeRepeatingSectionChildControl(W.SdtRun sdtRun) => + sdtRun.Ancestors().Any(IsNativeRepeatingSectionControl); + + private static bool IsNativeRepeatingSectionWithText(W.SdtRun sdtRun) => + IsNativeRepeatingSectionControl(sdtRun) && + GetNativeRepeatingSectionItems(sdtRun).Count > 0; + + private static IReadOnlyList GetNativeFormFieldControls(WordParagraph paragraph) { + if (paragraph._paragraph == null) { + return Array.Empty(); + } + + return paragraph._paragraph.Descendants() + .Where(IsNativeSupportedFormFieldContentControl) + .ToList(); + } + + private static IReadOnlyList GetNativeRepeatingSectionControls(WordParagraph paragraph) { + if (paragraph._paragraph == null) { + return Array.Empty(); + } + + return paragraph._paragraph.Descendants() + .Where(IsNativeRepeatingSectionControl) + .ToList(); + } + + private static bool IsNativeSupportedFormFieldContentControl(W.SdtRun sdtRun) => + IsNativeDatePickerControl(sdtRun) || + GetNativeChoiceFieldOptions(sdtRun).Count > 0; + + private static bool IsNativeDatePickerControl(W.SdtRun sdtRun) => + sdtRun.SdtProperties?.Elements().Any() == true; + + private static IReadOnlyList GetNativeChoiceFieldOptions(W.SdtRun sdtRun) { + W.SdtProperties? properties = sdtRun.SdtProperties; + if (properties == null) { + return Array.Empty(); + } + + IEnumerable items = properties.Elements().FirstOrDefault()?.Elements() ?? + properties.Elements().FirstOrDefault()?.Elements() ?? + Enumerable.Empty(); + + var options = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (W.ListItem item in items) { + string? option = item.DisplayText?.Value ?? item.Value?.Value; + if (string.IsNullOrWhiteSpace(option) || !seen.Add(option!)) { + continue; + } + + options.Add(option!); + } + + return options; + } + + private static string? GetNativeChoiceFieldValue(W.SdtRun sdtRun, IReadOnlyList options) { + W.SdtContentComboBox? comboBox = sdtRun.SdtProperties?.Elements().FirstOrDefault(); + string? lastValue = comboBox?.LastValue?.Value; + if (!string.IsNullOrWhiteSpace(lastValue)) { + string? displayValue = GetNativeChoiceDisplayValue(sdtRun, lastValue!); + if (!string.IsNullOrWhiteSpace(displayValue) && options.Contains(displayValue!, StringComparer.Ordinal)) { + return displayValue; + } + + if (options.Contains(lastValue!, StringComparer.Ordinal)) { + return lastValue; + } + } + + string? contentText = GetNativeSdtText(sdtRun); + if (!string.IsNullOrWhiteSpace(contentText) && options.Contains(contentText!, StringComparer.Ordinal)) { + return contentText; + } + + return options.Count > 0 ? options[0] : null; + } + + private static string? GetNativeChoiceDisplayValue(W.SdtRun sdtRun, string value) { + IEnumerable items = sdtRun.SdtProperties?.Elements().FirstOrDefault()?.Elements() ?? + sdtRun.SdtProperties?.Elements().FirstOrDefault()?.Elements() ?? + Enumerable.Empty(); + + W.ListItem? match = items.FirstOrDefault(item => string.Equals(item.Value?.Value, value, StringComparison.Ordinal)); + return match?.DisplayText?.Value ?? match?.Value?.Value; + } + + private static string GetNativeDatePickerValue(W.SdtRun sdtRun) { + W.SdtContentDate? datePicker = sdtRun.SdtProperties?.Elements().FirstOrDefault(); + if (datePicker?.FullDate?.Value is DateTime value) { + return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + + return GetNativeSdtText(sdtRun) ?? string.Empty; + } + + private static string? GetNativeSdtText(W.SdtRun sdtRun) { + if (sdtRun.SdtContentRun == null) { + return null; + } + + string text = string.Concat(sdtRun.SdtContentRun.Descendants().Select(runText => runText.Text)); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private static bool IsNativeSimpleTextContentControl(W.SdtRun sdtRun) { + W.SdtProperties? properties = sdtRun.SdtProperties; + if (properties == null) { + return false; + } + + if (properties.Elements().Any() || + properties.Elements().Any() || + properties.Elements().Any() || + properties.Elements().Any() || + properties.Elements().Any() || + properties.Elements().Any()) { + return false; + } + + return sdtRun.SdtContentRun?.Descendants().Any() == true; + } + + private static bool IsNativeCheckBoxChecked(W.SdtRun sdtRun) { + W14.SdtContentCheckBox? checkBox = sdtRun.SdtProperties?.Elements().FirstOrDefault(); + W14.Checked? checkedState = checkBox?.Elements().FirstOrDefault(); + return checkedState?.Val?.Value == W14.OnOffValues.One; + } + + private static string GetNativeCheckBoxFieldName(W.SdtRun sdtRun, int index, string fallbackPrefix = "WordCheckBox") { + return GetNativeContentControlFieldName(sdtRun, index, fallbackPrefix); + } + + private static string GetNativeContentControlFieldName(W.SdtRun sdtRun, int index, string fallbackPrefix) { + string? tag = sdtRun.SdtProperties?.Elements().FirstOrDefault()?.Val?.Value; + if (!string.IsNullOrWhiteSpace(tag)) { + return tag!; + } + + string? alias = sdtRun.SdtProperties?.Elements().FirstOrDefault()?.Val?.Value; + if (!string.IsNullOrWhiteSpace(alias)) { + return alias!; + } + + int? sdtId = sdtRun.SdtProperties?.Elements().FirstOrDefault()?.Val?.Value; + return sdtId.HasValue + ? fallbackPrefix + "." + sdtId.Value.ToString(CultureInfo.InvariantCulture) + : fallbackPrefix + "." + (index + 1).ToString(CultureInfo.InvariantCulture); + } + + private static void AddNativeExportWarning(PdfSaveOptions options, string code, string source, string message) { + options.Warnings.Add(new PdfExportWarning(code, source, message)); + } + + private static void ApplyNativeHeaderFooterPageNumberStyle(PdfCore.PdfPageCompose page, params NativeHeaderFooterText?[] parts) { + PdfCore.PdfPageNumberStyle? style = null; + foreach (NativeHeaderFooterText? part in parts) { + if (part?.PageNumberStyle == null) { + continue; + } + + if (style.HasValue && style.Value != part.PageNumberStyle.Value) { + return; + } + + style = part.PageNumberStyle.Value; + } + + if (style.HasValue) { + page.PageNumberStyle(style.Value); + } + } + + private static NativeHeaderFooterText? WithNativeFooterPageNumber(NativeHeaderFooterText? footer, bool includePageNumber, string pageNumberFormat) { + if (!includePageNumber) { + return footer; + } + + if (footer?.HasPageTokens == true) { + return footer; + } + + NativeHeaderFooterText result = footer?.Clone() ?? new NativeHeaderFooterText(); + result.AppendRight(pageNumberFormat); + return result; + } + + private static NativeHeaderFooterText? GetNativeHeaderFooterText(WordHeaderFooter? headerFooter) { + if (headerFooter == null) { + return null; + } + + var parts = new NativeHeaderFooterText(); + foreach (WordElement element in CollapseNativeParagraphElements(headerFooter.Elements)) { + switch (element) { + case WordParagraph paragraph: + AddNativeHeaderFooterParagraphText(parts, paragraph); + break; + case WordTable table: + AddNativeHeaderFooterTableText(parts, table); + break; + case WordHyperLink link when !string.IsNullOrWhiteSpace(link.Text): + parts.AppendLeft(link.Text); + break; + } + } + + return parts.HasContent ? parts : null; + } + + private static IReadOnlyList GetNativeHeaderFooterImages(WordHeaderFooter? headerFooter) { + if (headerFooter == null) { + return Array.Empty(); + } + + var images = new List(); + foreach (WordElement element in headerFooter.Elements) { + switch (element) { + case WordParagraph paragraph: + AddNativeHeaderFooterParagraphImage(images, paragraph, null); + break; + case WordTable table: + AddNativeHeaderFooterTableImages(images, table); + break; + } + } + + return images; + } + + private static IReadOnlyList GetNativeHeaderFooterShapes(WordHeaderFooter? headerFooter) { + if (headerFooter == null) { + return Array.Empty(); + } + + var shapes = new List(); + foreach (WordElement element in headerFooter.Elements) { + switch (element) { + case WordParagraph paragraph: + AddNativeHeaderFooterParagraphShape(shapes, paragraph, null); + break; + case WordTable table: + AddNativeHeaderFooterTableShapes(shapes, table); + break; + } + } + + return shapes; + } + + private static void AddNativeHeaderFooterParagraphText(NativeHeaderFooterText parts, WordParagraph paragraph) { + string? text = GetNativeHeaderFooterParagraphText(paragraph, out PdfCore.PdfPageNumberStyle? pageNumberStyle, out NativeHeaderFooterZone? zoneOverride); + + if (string.IsNullOrWhiteSpace(text)) { + return; + } + + string resolvedText = text!; + if (zoneOverride.HasValue) { + parts.Append(zoneOverride.Value, resolvedText, pageNumberStyle); + return; + } + + W.JustificationValues? alignment = paragraph.ParagraphAlignment; + if (alignment == W.JustificationValues.Center) { + parts.AppendCenter(resolvedText, pageNumberStyle); + } else if (alignment == W.JustificationValues.Right) { + parts.AppendRight(resolvedText, pageNumberStyle); + } else { + parts.AppendLeft(resolvedText, pageNumberStyle); + } + } + + private static void AddNativeHeaderFooterTableImages(List images, WordTable table) { + foreach (WordTableRow row in table.Rows) { + IReadOnlyList cells = row.Cells; + if (cells.Count == 1) { + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cells[0])) { + AddNativeHeaderFooterParagraphImage(images, paragraph, null); + } + + continue; + } + + for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { + PdfCore.PdfAlign align = cellIndex == 0 + ? PdfCore.PdfAlign.Left + : cellIndex == cells.Count - 1 + ? PdfCore.PdfAlign.Right + : PdfCore.PdfAlign.Center; + + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cells[cellIndex])) { + AddNativeHeaderFooterParagraphImage(images, paragraph, align); + } + } + } + } + + private static void AddNativeHeaderFooterParagraphImage(List images, WordParagraph paragraph, PdfCore.PdfAlign? alignOverride) { + PdfCore.PdfAlign align = alignOverride ?? MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false); + if (paragraph.Image != null) { + AddNativeHeaderFooterImage(images, paragraph.Image, align); + } + + foreach (W.SdtRun pictureControl in GetNativePictureControls(paragraph)) { + var pictureParagraph = new WordParagraph(paragraph._document, paragraph._paragraph!, pictureControl); + WordImage? pictureControlImage = pictureParagraph.PictureControl?.Image; + if (pictureControlImage == null) { + continue; + } + + AddNativeHeaderFooterImage(images, pictureControlImage, align); + } + } + + private static void AddNativeHeaderFooterImage(List images, WordImage image, PdfCore.PdfAlign align) { + byte[] bytes = ImageEmbedder.GetImageBytes(image); + double width = image.Width.HasValue ? image.Width.Value * 72D / 96D : 144D; + double height = image.Height.HasValue ? image.Height.Value * 72D / 96D : 144D; + images.Add(new NativeHeaderFooterImage(bytes, width, height, align)); + } + + private static void AddNativeHeaderFooterTableShapes(List shapes, WordTable table) { + foreach (WordTableRow row in table.Rows) { + IReadOnlyList cells = row.Cells; + if (cells.Count == 1) { + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cells[0])) { + AddNativeHeaderFooterParagraphShape(shapes, paragraph, null); + } + + continue; + } + + for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { + PdfCore.PdfAlign align = cellIndex == 0 + ? PdfCore.PdfAlign.Left + : cellIndex == cells.Count - 1 + ? PdfCore.PdfAlign.Right + : PdfCore.PdfAlign.Center; + + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cells[cellIndex])) { + AddNativeHeaderFooterParagraphShape(shapes, paragraph, align); + } + } + } + } + + private static void AddNativeHeaderFooterParagraphShape(List shapes, WordParagraph paragraph, PdfCore.PdfAlign? alignOverride) { + if (paragraph.Shape == null) { + return; + } + + OfficeShape? shape = CreateNativeShape(paragraph.Shape); + if (shape == null) { + return; + } + + PdfCore.PdfAlign align = alignOverride ?? MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false); + shapes.Add(new NativeHeaderFooterShape(shape, align)); + } + + private static string? GetNativeHeaderFooterParagraphText(WordParagraph paragraph, out PdfCore.PdfPageNumberStyle? pageNumberStyle) { + return GetNativeHeaderFooterParagraphText(paragraph, out pageNumberStyle, out _); + } + + private static string? GetNativeHeaderFooterParagraphText(WordParagraph paragraph, out PdfCore.PdfPageNumberStyle? pageNumberStyle, out NativeHeaderFooterZone? zoneOverride) { + zoneOverride = null; + if (TryBuildNativeHeaderFooterParagraphText(paragraph, out string? mixedText, out pageNumberStyle)) { + return AppendNativeHeaderFooterSupplementalText(mixedText, paragraph); + } + + if (TryGetNativeHeaderFooterFieldToken(paragraph, out string? fieldToken, out pageNumberStyle)) { + return AppendNativeHeaderFooterSupplementalText(fieldToken, paragraph); + } + + pageNumberStyle = null; + if (paragraph.IsHyperLink && paragraph.Hyperlink != null) { + return AppendNativeHeaderFooterSupplementalText(paragraph.Hyperlink.Text, paragraph); + } + + List runs = GetNativeRuns(paragraph); + string? text = runs.Count > 0 + ? string.Concat(runs.Select(run => run.Text)) + : paragraph.Text; + text = AppendNativeHeaderFooterSupplementalText(text, paragraph); + if (!string.IsNullOrWhiteSpace(text)) { + return text; + } + + string? textBoxText = GetNativeParagraphTextBoxPlainText(paragraph); + if (string.IsNullOrWhiteSpace(textBoxText)) { + return text; + } + + WordTextBox? textBox = GetNativeParagraphTextBox(paragraph, out _); + zoneOverride = MapNativeTextBoxHeaderFooterZone(textBox?.HorizontalAlignment ?? WordHorizontalAlignmentValues.Center); + return textBoxText; + } + + private static string? AppendNativeHeaderFooterSupplementalText(string? text, WordParagraph paragraph) { + text = AppendNativeHeaderFooterEquationText(text, paragraph); + text = AppendNativeHeaderFooterFormControlText(text, paragraph); + return AppendNativeHeaderFooterRepeatingSectionText(text, paragraph); + } + + private static string? AppendNativeHeaderFooterEquationText(string? text, WordParagraph paragraph) { + string? equationText = GetNativeEquationText(paragraph); + if (string.IsNullOrWhiteSpace(equationText)) { + return text; + } + + var builder = new StringBuilder(text ?? string.Empty); + string currentText = builder.ToString(); + AppendNativeHeaderFooterSupplementalValue(builder, ref currentText, equationText, skipIfPresent: true); + return builder.Length == 0 ? text : builder.ToString(); + } + + private static string? AppendNativeHeaderFooterFormControlText(string? text, WordParagraph paragraph) { + IReadOnlyList checkBoxes = GetNativeCheckBoxControls(paragraph); + IReadOnlyList formFields = GetNativeFormFieldControls(paragraph); + if (checkBoxes.Count == 0 && formFields.Count == 0) { + return text; + } + + var builder = new StringBuilder(text ?? string.Empty); + string currentText = builder.ToString(); + foreach (W.SdtRun checkBox in checkBoxes) { + AppendNativeHeaderFooterSupplementalValue( + builder, + ref currentText, + IsNativeCheckBoxChecked(checkBox) ? "[x]" : "[ ]", + skipIfPresent: false); + } + + foreach (W.SdtRun formField in formFields) { + string? value; + if (IsNativeDatePickerControl(formField)) { + value = GetNativeDatePickerValue(formField); + } else { + IReadOnlyList options = GetNativeChoiceFieldOptions(formField); + value = GetNativeChoiceFieldValue(formField, options); + } + + AppendNativeHeaderFooterSupplementalValue(builder, ref currentText, value, skipIfPresent: true); + } + + return builder.Length == 0 ? text : builder.ToString(); + } + + private static string? AppendNativeHeaderFooterRepeatingSectionText(string? text, WordParagraph paragraph) { + IReadOnlyList controls = GetNativeRepeatingSectionControls(paragraph); + if (controls.Count == 0) { + return text; + } + + var builder = new StringBuilder(text ?? string.Empty); + string currentText = builder.ToString(); + foreach (W.SdtRun control in controls) { + foreach (string itemText in GetNativeRepeatingSectionItems(control)) { + AppendNativeHeaderFooterSupplementalValue(builder, ref currentText, itemText, skipIfPresent: true); + } + } + + return builder.Length == 0 ? text : builder.ToString(); + } + + private static void AppendNativeHeaderFooterSupplementalValue(StringBuilder builder, ref string currentText, string? value, bool skipIfPresent) { + if (string.IsNullOrWhiteSpace(value) || + skipIfPresent && currentText.IndexOf(value!, StringComparison.Ordinal) >= 0) { + return; + } + + if (builder.Length > 0 && !char.IsWhiteSpace(builder[builder.Length - 1])) { + builder.Append(' '); + } + + builder.Append(value); + currentText = builder.ToString(); + } + + private static string AppendNativeTextWithEquation(string text, WordParagraph paragraph) { + string? equationText = GetNativeEquationText(paragraph); + if (string.IsNullOrWhiteSpace(equationText) || + text.IndexOf(equationText!, StringComparison.Ordinal) >= 0) { + return text; + } + + if (string.IsNullOrEmpty(text)) { + return equationText!; + } + + return char.IsWhiteSpace(text[text.Length - 1]) + ? text + equationText + : text + " " + equationText; + } + + private static string? GetNativeEquationText(WordParagraph paragraph) { + var parts = new List(); + AddNativeEquationText(parts, paragraph._officeMath); + AddNativeEquationText(parts, paragraph._mathParagraph); + if (parts.Count == 0) { + AddNativeParagraphEquationText(parts, paragraph._paragraph); + } + + string text = string.Concat(parts); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private static void AddNativeParagraphEquationText(List parts, W.Paragraph? paragraph) { + if (paragraph == null || + (!paragraph.Descendants().Any() && + !paragraph.Descendants().Any())) { + return; + } + + int startCount = parts.Count; + foreach (DocumentFormat.OpenXml.Math.Text text in paragraph.Descendants()) { + if (!string.IsNullOrEmpty(text.Text)) { + parts.Add(text.Text); + } + } + + if (parts.Count > startCount) { + return; + } + + foreach (DocumentFormat.OpenXml.Math.OfficeMath officeMath in paragraph.Descendants()) { + AddNativeEquationText(parts, officeMath); + if (parts.Count > startCount) { + return; + } + } + + foreach (DocumentFormat.OpenXml.Math.Paragraph mathParagraph in paragraph.Descendants()) { + AddNativeEquationText(parts, mathParagraph); + if (parts.Count > startCount) { + return; + } + } + } + + private static void AddNativeEquationText(List parts, DocumentFormat.OpenXml.OpenXmlElement? equationElement) { + if (equationElement == null) { + return; + } + + int startCount = parts.Count; + foreach (DocumentFormat.OpenXml.Math.Text text in equationElement.Descendants()) { + if (!string.IsNullOrEmpty(text.Text)) { + parts.Add(text.Text); + } + } + + if (parts.Count > startCount) { + return; + } + + AddNativeEquationXmlText(parts, equationElement.OuterXml); + if (parts.Count > startCount) { + return; + } + + AddNativeEquationXmlText(parts, equationElement.InnerXml); + if (parts.Count == startCount && !string.IsNullOrWhiteSpace(equationElement.InnerText) && equationElement.InnerText.IndexOf('<') < 0) { + parts.Add(equationElement.InnerText); + } + } + + private static void AddNativeEquationXmlText(List parts, string? xml) { + if (string.IsNullOrWhiteSpace(xml)) { + return; + } + + try { + System.Xml.Linq.XElement root = System.Xml.Linq.XElement.Parse(xml!); + foreach (System.Xml.Linq.XElement textElement in root.Descendants().Where(element => + string.Equals(element.Name.LocalName, "t", StringComparison.Ordinal) && + element.Name.NamespaceName.IndexOf("officeDocument/2006/math", StringComparison.OrdinalIgnoreCase) >= 0)) { + if (!string.IsNullOrEmpty(textElement.Value)) { + parts.Add(textElement.Value); + } + } + } catch (System.Xml.XmlException) { + // Some legacy equation wrappers may expose typed math nodes without valid standalone XML. + } + } + + private static NativeHeaderFooterZone MapNativeTextBoxHeaderFooterZone(WordHorizontalAlignmentValues alignment) { + switch (alignment) { + case WordHorizontalAlignmentValues.Center: + return NativeHeaderFooterZone.Center; + case WordHorizontalAlignmentValues.Right: + case WordHorizontalAlignmentValues.Outside: + return NativeHeaderFooterZone.Right; + default: + return NativeHeaderFooterZone.Left; + } + } + + private static bool TryBuildNativeHeaderFooterParagraphText(WordParagraph paragraph, out string? text, out PdfCore.PdfPageNumberStyle? pageNumberStyle) { + text = null; + pageNumberStyle = null; + if (paragraph._paragraph == null) { + return false; + } + + var builder = new StringBuilder(); + var state = new NativeHeaderFooterFieldState(); + bool hasFieldToken = false; + bool hasConflictingStyles = false; + foreach (var element in paragraph._paragraph.ChildElements) { + AppendNativeHeaderFooterElementText(element, builder, state, ref pageNumberStyle, ref hasConflictingStyles, ref hasFieldToken); + } + + if (!hasFieldToken) { + pageNumberStyle = null; + return false; + } + + text = builder.ToString(); + return !string.IsNullOrWhiteSpace(text); + } + + private static void AppendNativeHeaderFooterElementText(DocumentFormat.OpenXml.OpenXmlElement element, StringBuilder builder, NativeHeaderFooterFieldState state, ref PdfCore.PdfPageNumberStyle? pageNumberStyle, ref bool hasConflictingStyles, ref bool hasFieldToken) { + if (element is W.Run run) { + AppendNativeHeaderFooterRunText(run, builder, state, ref pageNumberStyle, ref hasConflictingStyles, ref hasFieldToken); + return; + } + + if (element is W.Hyperlink hyperlink) { + foreach (W.Run childRun in hyperlink.Elements()) { + AppendNativeHeaderFooterRunText(childRun, builder, state, ref pageNumberStyle, ref hasConflictingStyles, ref hasFieldToken); + } + + return; + } + + if (element is W.SdtRun sdtRun) { + foreach (var child in sdtRun.SdtContentRun?.ChildElements ?? Enumerable.Empty()) { + AppendNativeHeaderFooterElementText(child, builder, state, ref pageNumberStyle, ref hasConflictingStyles, ref hasFieldToken); + } + + return; + } + + if (element is W.SimpleField simpleField) { + string fieldCode = simpleField.Instruction?.Value ?? string.Empty; + if (TryGetNativeHeaderFooterFieldToken(fieldCode, out string? token, out PdfCore.PdfPageNumberStyle? style)) { + builder.Append(token); + MergeNativeHeaderFooterPageNumberStyle(ref pageNumberStyle, ref hasConflictingStyles, style); + hasFieldToken = true; + return; + } + + foreach (var child in simpleField.ChildElements) { + AppendNativeHeaderFooterElementText(child, builder, state, ref pageNumberStyle, ref hasConflictingStyles, ref hasFieldToken); + } + } + } + + private static void AppendNativeHeaderFooterRunText(W.Run run, StringBuilder builder, NativeHeaderFooterFieldState state, ref PdfCore.PdfPageNumberStyle? pageNumberStyle, ref bool hasConflictingStyles, ref bool hasFieldToken) { + foreach (var child in run.ChildElements) { + if (child is W.FieldChar fieldChar) { + W.FieldCharValues? fieldCharType = fieldChar.FieldCharType?.Value; + if (fieldCharType == W.FieldCharValues.Begin) { + state.CollectingFieldCode = true; + state.SkippingFieldResult = false; + state.FieldCode.Clear(); + } else if (fieldCharType == W.FieldCharValues.Separate) { + if (TryGetNativeHeaderFooterFieldToken(state.FieldCode.ToString(), out string? token, out PdfCore.PdfPageNumberStyle? style)) { + builder.Append(token); + MergeNativeHeaderFooterPageNumberStyle(ref pageNumberStyle, ref hasConflictingStyles, style); + hasFieldToken = true; + state.SkippingFieldResult = true; + } + + state.CollectingFieldCode = false; + } else if (fieldCharType == W.FieldCharValues.End) { + state.CollectingFieldCode = false; + state.SkippingFieldResult = false; + state.FieldCode.Clear(); + } + + continue; + } + + if (child is W.FieldCode fieldCode) { + if (state.CollectingFieldCode) { + state.FieldCode.Append(fieldCode.Text); + } + + continue; + } + + if (state.CollectingFieldCode || state.SkippingFieldResult) { + continue; + } + + if (child is W.Text text) { + builder.Append(text.Text); + } else if (child is W.TabChar) { + builder.Append('\t'); + } else if (child is W.Break) { + builder.AppendLine(); + } + } + } + + private static bool TryGetNativeHeaderFooterFieldToken(WordParagraph paragraph, out string? token, out PdfCore.PdfPageNumberStyle? style) { + token = null; + style = null; + WordField? field = paragraph.Field; + if (field?.FieldType == WordFieldType.Page) { + token = "{page}"; + style = MapNativePageNumberFieldStyle(field.Field); + return true; + } + + if (field?.FieldType == WordFieldType.NumPages) { + token = "{pages}"; + style = MapNativePageNumberFieldStyle(field.Field); + return true; + } + + return false; + } + + private static bool TryGetNativeHeaderFooterFieldToken(string fieldCode, out string? token, out PdfCore.PdfPageNumberStyle? style) { + token = null; + style = null; + string trimmed = fieldCode.Trim(); + if (trimmed.Length == 0) { + return false; + } + + int end = 0; + while (end < trimmed.Length && !char.IsWhiteSpace(trimmed[end])) { + end++; + } + + string fieldType = trimmed.Substring(0, end); + if (string.Equals(fieldType, "PAGE", StringComparison.OrdinalIgnoreCase)) { + token = "{page}"; + style = MapNativePageNumberFieldStyle(trimmed); + return true; + } + + if (string.Equals(fieldType, "NUMPAGES", StringComparison.OrdinalIgnoreCase)) { + token = "{pages}"; + style = MapNativePageNumberFieldStyle(trimmed); + return true; + } + + return false; + } + + private static PdfCore.PdfPageNumberStyle? MapNativePageNumberFieldStyle(string fieldCode) { + string? format = GetNativePageNumberFieldFormatSwitch(fieldCode); + if (format == "roman") { + return PdfCore.PdfPageNumberStyle.LowerRoman; + } + + if (format == "Roman") { + return PdfCore.PdfPageNumberStyle.UpperRoman; + } + + if (format == "Alphabetical") { + return PdfCore.PdfPageNumberStyle.LowerLetter; + } + + if (format == "ALPHABETICAL") { + return PdfCore.PdfPageNumberStyle.UpperLetter; + } + + if (format == "Arabic") { + return PdfCore.PdfPageNumberStyle.Arabic; + } + + return null; + } + + private static string? GetNativePageNumberFieldFormatSwitch(string fieldCode) { + int markerIndex = fieldCode.IndexOf(@"\*", StringComparison.Ordinal); + while (markerIndex >= 0) { + int index = markerIndex + 2; + while (index < fieldCode.Length && char.IsWhiteSpace(fieldCode[index])) { + index++; + } + + int start = index; + while (index < fieldCode.Length && (char.IsLetter(fieldCode[index]) || fieldCode[index] == '_')) { + index++; + } + + if (index > start) { + return fieldCode.Substring(start, index - start); + } + + markerIndex = fieldCode.IndexOf(@"\*", markerIndex + 2, StringComparison.Ordinal); + } + + return null; + } + + private static void AddNativeHeaderFooterTableText(NativeHeaderFooterText parts, WordTable table) { + foreach (WordTableRow row in table.Rows) { + IReadOnlyList cells = row.Cells; + if (cells.Count == 1) { + AddNativeHeaderFooterSingleCellText(parts, cells[0]); + continue; + } + + for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++) { + NativeHeaderFooterZone zone = cellIndex == 0 + ? NativeHeaderFooterZone.Left + : cellIndex == cells.Count - 1 + ? NativeHeaderFooterZone.Right + : NativeHeaderFooterZone.Center; + + AddNativeHeaderFooterCellText(parts, cells[cellIndex], zone); + } + } + } + + private static void AddNativeHeaderFooterSingleCellText(NativeHeaderFooterText parts, WordTableCell cell) { + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + AddNativeHeaderFooterParagraphText(parts, paragraph); + } + } + + private static void AddNativeHeaderFooterCellText(NativeHeaderFooterText parts, WordTableCell cell, NativeHeaderFooterZone zone) { + string cellText = GetNativeHeaderFooterCellText(cell, out PdfCore.PdfPageNumberStyle? pageNumberStyle); + if (!string.IsNullOrWhiteSpace(cellText)) { + parts.Append(zone, cellText, pageNumberStyle); + } + } + + private static string GetNativeHeaderFooterCellText(WordTableCell cell, out PdfCore.PdfPageNumberStyle? pageNumberStyle) { + var parts = new List(); + pageNumberStyle = null; + bool hasConflictingStyles = false; + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + string? text = GetNativeHeaderFooterParagraphText(paragraph, out PdfCore.PdfPageNumberStyle? paragraphStyle); + if (!string.IsNullOrEmpty(text)) { + parts.Add(text!); + MergeNativeHeaderFooterPageNumberStyle(ref pageNumberStyle, ref hasConflictingStyles, paragraphStyle); + } + } + + return string.Join(Environment.NewLine, parts); + } + + private static void MergeNativeHeaderFooterPageNumberStyle(ref PdfCore.PdfPageNumberStyle? current, ref bool hasConflict, PdfCore.PdfPageNumberStyle? candidate) { + if (!candidate.HasValue || hasConflict) { + return; + } + + if (current.HasValue && current.Value != candidate.Value) { + current = null; + hasConflict = true; + return; + } + + current = candidate.Value; + } + + private enum NativeHeaderFooterZone { + Left, + Center, + Right + } + + private sealed class NativeHeaderFooterFieldState { + public bool CollectingFieldCode { get; set; } + public bool SkippingFieldResult { get; set; } + public StringBuilder FieldCode { get; } = new StringBuilder(); + } + + private sealed class NativeHeaderFooterImage { + public NativeHeaderFooterImage(byte[] data, double width, double height, PdfCore.PdfAlign align) { + Data = data; + Width = width; + Height = height; + Align = align; + } + + public byte[] Data { get; } + public double Width { get; } + public double Height { get; } + public PdfCore.PdfAlign Align { get; } + } + + private sealed class NativeHeaderFooterShape { + public NativeHeaderFooterShape(OfficeShape shape, PdfCore.PdfAlign align) { + Shape = shape.Clone(); + Align = align; + } + + public OfficeShape Shape { get; } + public PdfCore.PdfAlign Align { get; } + } + + private sealed class NativeHeaderFooterText { + public string? Left { get; private set; } + public string? Center { get; private set; } + public string? Right { get; private set; } + public bool HasPageTokens { get; private set; } + public PdfCore.PdfPageNumberStyle? PageNumberStyle { get; private set; } + private bool _hasConflictingPageNumberStyles; + public bool HasContent => + !string.IsNullOrWhiteSpace(Left) || + !string.IsNullOrWhiteSpace(Center) || + !string.IsNullOrWhiteSpace(Right); + + public void AppendLeft(string text) => Left = Append(Left, text, null); + public void AppendCenter(string text) => Center = Append(Center, text, null); + public void AppendRight(string text) => Right = Append(Right, text, null); + public void AppendLeft(string text, PdfCore.PdfPageNumberStyle? pageNumberStyle) => Left = Append(Left, text, pageNumberStyle); + public void AppendCenter(string text, PdfCore.PdfPageNumberStyle? pageNumberStyle) => Center = Append(Center, text, pageNumberStyle); + public void AppendRight(string text, PdfCore.PdfPageNumberStyle? pageNumberStyle) => Right = Append(Right, text, pageNumberStyle); + + public void Append(NativeHeaderFooterZone zone, string text) => Append(zone, text, null); + + public void Append(NativeHeaderFooterZone zone, string text, PdfCore.PdfPageNumberStyle? pageNumberStyle) { + switch (zone) { + case NativeHeaderFooterZone.Center: + AppendCenter(text, pageNumberStyle); + break; + case NativeHeaderFooterZone.Right: + AppendRight(text, pageNumberStyle); + break; + default: + AppendLeft(text, pageNumberStyle); + break; + } + } + + public NativeHeaderFooterText Clone() { + return new NativeHeaderFooterText { + Left = Left, + Center = Center, + Right = Right, + HasPageTokens = HasPageTokens, + PageNumberStyle = PageNumberStyle, + _hasConflictingPageNumberStyles = _hasConflictingPageNumberStyles + }; + } + + private string Append(string? current, string text, PdfCore.PdfPageNumberStyle? pageNumberStyle) { + if (text.IndexOf("{page}", StringComparison.OrdinalIgnoreCase) >= 0 || + text.IndexOf("{pages}", StringComparison.OrdinalIgnoreCase) >= 0) { + HasPageTokens = true; + } + + RecordPageNumberStyle(pageNumberStyle); + return string.IsNullOrWhiteSpace(current) ? text : current + " " + text; + } + + private void RecordPageNumberStyle(PdfCore.PdfPageNumberStyle? style) { + if (!style.HasValue || _hasConflictingPageNumberStyles) { + return; + } + + if (PageNumberStyle.HasValue && PageNumberStyle.Value != style.Value) { + PageNumberStyle = null; + _hasConflictingPageNumberStyles = true; + return; + } + + PageNumberStyle = style.Value; + } + } + + private static PdfCore.PdfOptions CreateNativeOptions(WordDocument document, PdfSaveOptions? options) { + WordSection? firstSection = document.Sections.FirstOrDefault(); + PdfCore.PdfStandardFont defaultFont = GetNativeDefaultFont(document, options); + return new PdfCore.PdfOptions { + PageSize = firstSection == null ? PdfCore.PageSizes.A4 : GetNativePageSize(firstSection, options), + Margins = firstSection == null ? PdfCore.PageMargins.Uniform(72) : GetNativeMargins(firstSection, options), + DefaultFont = defaultFont, + HeaderFont = defaultFont, + FooterFont = defaultFont, + BackgroundColor = ParseNativeColor(document.Background?.Color), + CreateOutlineFromHeadings = true + }; + } + + private static PdfCore.PdfStandardFont GetNativeDefaultFont(WordDocument document, PdfSaveOptions? options) { + if (TryMapNativeFontFamily(options?.FontFamily, out PdfCore.PdfStandardFont optionFont)) { + return optionFont; + } + + if (TryMapNativeFontFamily(document.Settings.FontFamily, out PdfCore.PdfStandardFont settingsFont) || + TryMapNativeFontFamily(document.Settings.FontFamilyHighAnsi, out settingsFont) || + TryMapNativeFontFamily(document.Settings.FontFamilyEastAsia, out settingsFont) || + TryMapNativeFontFamily(document.Settings.FontFamilyComplexScript, out settingsFont)) { + return settingsFont; + } + + return PdfCore.PdfStandardFont.Helvetica; + } + + private static bool TryMapNativeFontFamily(string? fontFamily, out PdfCore.PdfStandardFont font) { + font = PdfCore.PdfStandardFont.Helvetica; + if (string.IsNullOrWhiteSpace(fontFamily)) { + return false; + } + + string normalized = NormalizeNativeFontFamily(fontFamily!); + switch (normalized) { + case "timesnewroman": + case "times": + case "timesroman": + case "georgia": + case "cambria": + case "serif": + font = PdfCore.PdfStandardFont.TimesRoman; + return true; + case "couriernew": + case "courier": + case "consolas": + case "lucidaconsole": + case "monospace": + font = PdfCore.PdfStandardFont.Courier; + return true; + case "arial": + case "helvetica": + case "calibri": + case "aptos": + case "segoeui": + case "tahoma": + case "verdana": + case "sans": + case "sansserif": + font = PdfCore.PdfStandardFont.Helvetica; + return true; + default: + return false; + } + } + + private static string NormalizeNativeFontFamily(string fontFamily) { + string firstFamily = fontFamily.Split(new[] { ',', ';' }, 2)[0]; + var builder = new StringBuilder(firstFamily.Length); + foreach (char ch in firstFamily) { + if (char.IsLetterOrDigit(ch)) { + builder.Append(char.ToLowerInvariant(ch)); + } + } + + return builder.ToString(); + } + + private sealed class NativeTableOfContentsEntry { + public NativeTableOfContentsEntry(string text, int level, int pageNumber, string? destinationName) { + Text = text; + Level = level; + PageNumber = pageNumber; + DestinationName = destinationName; + } + + public string Text { get; } + public int Level { get; } + public int PageNumber { get; } + public string? DestinationName { get; } + } + + private static Dictionary BuildNativeHeadingDestinations(WordDocument document) { + var destinations = new Dictionary(); + var used = new HashSet(StringComparer.Ordinal); + int headingIndex = 0; + + foreach (WordSection section in document.Sections) { + foreach (WordElement element in CollapseNativeParagraphElements(section.Elements)) { + if (element is not WordParagraph paragraph || + paragraph._paragraph == null || + GetNativeTableOfContentsHeadingLevel(paragraph) <= 0) { + continue; + } + + string headingText = GetNativeParagraphDisplayText(paragraph); + if (string.IsNullOrWhiteSpace(headingText)) { + continue; + } + + string? bookmarkName = string.IsNullOrWhiteSpace(paragraph.Bookmark?.Name) + ? null + : paragraph.Bookmark!.Name; + string destinationName = bookmarkName ?? CreateNativeHeadingDestinationName(headingText, ++headingIndex, used); + destinations[paragraph._paragraph] = destinationName; + used.Add(destinationName); + } + } + + return destinations; + } + + private static string CreateNativeHeadingDestinationName(string text, int headingIndex, HashSet used) { + var builder = new StringBuilder("officeimo-heading-"); + foreach (char ch in text) { + if (char.IsLetterOrDigit(ch)) { + builder.Append(char.ToLowerInvariant(ch)); + } else if (builder[builder.Length - 1] != '-') { + builder.Append('-'); + } + + if (builder.Length >= 80) { + break; + } + } + + string baseName = builder.ToString().TrimEnd('-'); + if (baseName.Length <= "officeimo-heading".Length) { + baseName = "officeimo-heading-" + headingIndex.ToString(CultureInfo.InvariantCulture); + } + + string name = baseName; + int suffix = 2; + while (used.Contains(name)) { + name = baseName + "-" + suffix.ToString(CultureInfo.InvariantCulture); + suffix++; + } + + return name; + } + + private static IReadOnlyList BuildNativeTableOfContentsEntries(WordDocument document, PdfSaveOptions? options, IReadOnlyDictionary headingDestinations) { + var entries = new List(); + int headingCount = CountNativeDocumentHeadings(document); + int currentPage = 1; + double consumedOnPage = 0D; + + foreach (WordSection section in document.Sections) { + PdfCore.PageSize pageSize = GetNativePageSize(section, options); + PdfCore.PageMargins margins = GetNativeMargins(section, options); + double contentHeight = Math.Max(72D, pageSize.Height - margins.Top - margins.Bottom); + double contentWidth = Math.Max(72D, pageSize.Width - margins.Left - margins.Right); + + foreach (WordElement element in CollapseNativeParagraphElements(section.Elements)) { + if (element is WordParagraph paragraph && paragraph.PageBreakBefore) { + currentPage++; + consumedOnPage = 0D; + } + + if (element is WordParagraph headingParagraph) { + int headingLevel = GetNativeTableOfContentsHeadingLevel(headingParagraph); + if (headingLevel > 0) { + string headingText = GetNativeParagraphDisplayText(headingParagraph); + if (!string.IsNullOrWhiteSpace(headingText)) { + string? destinationName = headingParagraph._paragraph != null && + headingDestinations.TryGetValue(headingParagraph._paragraph, out string? foundDestination) + ? foundDestination + : null; + entries.Add(new NativeTableOfContentsEntry(headingText, headingLevel, currentPage, destinationName)); + } + } + } + + if (element is WordParagraph pageBreakParagraph && pageBreakParagraph.IsPageBreak) { + currentPage++; + consumedOnPage = 0D; + continue; + } + + if (element is WordBreak wordBreak && wordBreak.BreakType == W.BreakValues.Page) { + currentPage++; + consumedOnPage = 0D; + continue; + } + + double estimatedHeight = EstimateNativeElementHeight(element, contentWidth, headingCount); + if (estimatedHeight <= 0D) { + continue; + } + + if (consumedOnPage > 0D && consumedOnPage + estimatedHeight > contentHeight) { + currentPage++; + consumedOnPage = 0D; + } + + consumedOnPage += estimatedHeight; + while (consumedOnPage > contentHeight) { + currentPage++; + consumedOnPage -= contentHeight; + } + } + } + + return entries; + } + + private static int CountNativeDocumentHeadings(WordDocument document) { + int count = 0; + foreach (WordSection section in document.Sections) { + foreach (WordElement element in CollapseNativeParagraphElements(section.Elements)) { + if (element is WordParagraph paragraph && + GetNativeTableOfContentsHeadingLevel(paragraph) > 0 && + !string.IsNullOrWhiteSpace(GetNativeParagraphDisplayText(paragraph))) { + count++; + } + } + } + + return count; + } + + private static double EstimateNativeElementHeight(WordElement element, double contentWidth, int headingCount) { + switch (element) { + case WordTableOfContent: + return 18D + Math.Max(1, headingCount) * 15D + 10D; + case WordTable table: + return EstimateNativeTableHeight(table, contentWidth); + case WordImage image: + return image.Height.HasValue ? image.Height.Value * 72D / 96D + 6D : 150D; + case WordParagraph paragraph: + return EstimateNativeParagraphHeight(paragraph, contentWidth); + default: + return 0D; + } + } + + private static double EstimateNativeTableHeight(WordTable table, double contentWidth) { + int rowCount = Math.Max(1, table.Rows.Count); + int columnCount = Math.Max(1, table.Rows.Select(row => row.Cells.Count).DefaultIfEmpty(1).Max()); + double cellWidth = Math.Max(48D, contentWidth / columnCount); + double height = 0D; + foreach (WordTableRow row in table.Rows) { + int rowLines = 1; + foreach (WordTableCell cell in row.Cells) { + string cellText = GetNativeCellText(cell); + rowLines = Math.Max(rowLines, EstimateNativeLineCount(cellText, cellWidth, 10D)); + } + + height += rowLines * 14D + 12D; + } + + return Math.Max(rowCount * 22D, height) + 6D; + } + + private static double EstimateNativeParagraphHeight(WordParagraph paragraph, double contentWidth) { + if (paragraph.IsPageBreak) { + return 0D; + } + + string text = GetNativeParagraphDisplayText(paragraph); + if (string.IsNullOrWhiteSpace(text) && + paragraph.Image == null && + paragraph.Shape == null && + paragraph.PictureControl?.Image == null) { + return 0D; + } + + int headingLevel = GetNativeTableOfContentsHeadingLevel(paragraph); + if (headingLevel > 0) { + double headingSize = headingLevel == 1 ? 18D : headingLevel == 2 ? 15D : 13D; + return EstimateNativeLineCount(text, contentWidth, headingSize) * headingSize * 1.25D + 8D; + } + + double fontSize = paragraph.FontSize.HasValue && paragraph.FontSize.Value > 0 ? paragraph.FontSize.Value : 11D; + double height = EstimateNativeLineCount(text, contentWidth, fontSize) * fontSize * NativeDefaultParagraphLineHeight + NativeDefaultParagraphSpacingAfter; + if (!string.IsNullOrWhiteSpace(paragraph.ShadingFillColorHex) || + HasNativeBorder(paragraph.Borders.TopStyle) || + HasNativeBorder(paragraph.Borders.BottomStyle) || + HasNativeBorder(paragraph.Borders.LeftStyle) || + HasNativeBorder(paragraph.Borders.RightStyle)) { + height += 8D; + } + + return height; + } + + private static int EstimateNativeLineCount(string? text, double contentWidth, double fontSize) { + if (string.IsNullOrEmpty(text)) { + return 1; + } + + double averageCharacterWidth = Math.Max(3D, fontSize * 0.48D); + int charactersPerLine = Math.Max(12, (int)Math.Floor(contentWidth / averageCharacterWidth)); + int lines = 0; + foreach (string part in text!.Replace("\r\n", "\n").Split('\n')) { + lines += Math.Max(1, (int)Math.Ceiling(part.Length / (double)charactersPerLine)); + } + + return Math.Max(1, lines); + } + + private static string GetNativeParagraphDisplayText(WordParagraph paragraph) { + if (paragraph.IsHyperLink && paragraph.Hyperlink != null) { + return paragraph.Hyperlink.Text; + } + + List runs = GetNativeRuns(paragraph); + string text = runs.Count > 0 + ? string.Concat(runs.Where(run => !run.IsImage).Select(run => run.Text)) + : paragraph.Text; + return AppendNativeTextWithEquation(text, paragraph); + } + + private static int GetNativeTableOfContentsHeadingLevel(WordParagraph paragraph) { + if (!paragraph.Style.HasValue) { + return 0; + } + + return paragraph.Style.Value switch { + WordParagraphStyles.Heading1 => 1, + WordParagraphStyles.Heading2 => 2, + WordParagraphStyles.Heading3 => 3, + WordParagraphStyles.Heading4 => 4, + WordParagraphStyles.Heading5 => 5, + WordParagraphStyles.Heading6 => 6, + WordParagraphStyles.Heading7 => 7, + WordParagraphStyles.Heading8 => 8, + WordParagraphStyles.Heading9 => 9, + _ => 0 + }; + } + + private static void RenderNativeTableOfContents(INativePdfFlow pdf, WordTableOfContent tableOfContent, IReadOnlyList entries) { + string title = string.IsNullOrWhiteSpace(tableOfContent.Text) ? "Table of Contents" : tableOfContent.Text; + pdf.Paragraph(builder => builder.FontSize(11D).Text(title), PdfCore.PdfAlign.Left, null, new PdfCore.PdfParagraphStyle { + SpacingAfter = 5D, + KeepWithNext = true + }); + + int minLevel = tableOfContent.MinLevel; + int maxLevel = tableOfContent.MaxLevel; + int rendered = 0; + foreach (NativeTableOfContentsEntry entry in entries) { + if (entry.Level < minLevel || entry.Level > maxLevel) { + continue; + } + + int relativeLevel = Math.Max(0, entry.Level - minLevel); + var style = new PdfCore.PdfParagraphStyle { + LeftIndent = relativeLevel * 14D, + SpacingAfter = 1D, + DefaultTabStopWidth = 432D, + KeepWithNext = true + }; + pdf.Paragraph( + builder => { + builder.FontSize(10.5D); + if (string.IsNullOrEmpty(entry.DestinationName)) { + builder.Text(entry.Text); + } else { + builder.LinkToBookmark(entry.Text, entry.DestinationName!, underline: false, contents: "Table of contents: " + entry.Text); + } + + builder + .Tab(PdfCore.PdfTabLeaderStyle.Dots, PdfCore.PdfTabAlignment.Right) + .Text(entry.PageNumber.ToString(CultureInfo.InvariantCulture)); + }, + PdfCore.PdfAlign.Left, + null, + style); + rendered++; + } + + if (rendered == 0) { + string fallback = string.IsNullOrWhiteSpace(tableOfContent.TextNoContent) + ? "No table of contents entries found." + : tableOfContent.TextNoContent; + pdf.Paragraph(builder => builder.FontSize(10.5D).Text(fallback)); + } + } + + private static void RenderNativeElement(INativePdfFlow pdf, WordElement element, Func getMarker, IReadOnlyList footnoteNumbers, Dictionary footnoteNumbersById, PdfSaveOptions? options, IReadOnlyList tableOfContentsEntries, IReadOnlyDictionary headingDestinations) { + switch (element) { + case WordParagraph paragraph: + RenderNativeParagraph(pdf, paragraph, getMarker(paragraph), footnoteNumbers, footnoteNumbersById, options, headingDestinations); + break; + case WordTableOfContent tableOfContent: + RenderNativeTableOfContents(pdf, tableOfContent, tableOfContentsEntries); + break; + case WordTable table: + RenderNativeTable(pdf, table, getMarker, footnoteNumbersById, options); + break; + case WordImage image: + RenderNativeImage(pdf, image); + break; + case WordHyperLink link: + RenderNativeHyperLink(pdf, link); + break; + case WordBreak wordBreak: + RenderNativeBreak(pdf, wordBreak); + break; + case WordShape shape: + RenderNativeShape(pdf, shape); + break; + case WordEmbeddedDocument: + if (options != null) { + AddNativeExportWarning( + options, + "NativeBodyEmbeddedDocumentUnsupported", + "body", + "Embedded documents in Word body content are not mapped by the OfficeIMO PDF engine yet."); + } + + break; + default: + if (options != null) { + AddNativeExportWarning( + options, + "NativeBodyElementUnsupported", + "body", + "Word body element '" + element.GetType().Name + "' is not mapped by the OfficeIMO PDF engine yet."); + } + + break; + } + } + + private static void RenderNativeBreak(INativePdfFlow pdf, WordBreak wordBreak) { + if (wordBreak.BreakType == W.BreakValues.Page) { + pdf.PageBreak(); + } + } + + private static void RenderNativeParagraph(INativePdfFlow pdf, WordParagraph paragraph, (int Level, string Marker)? marker, IReadOnlyList footnoteNumbers, Dictionary footnoteNumbersById, PdfSaveOptions? options, IReadOnlyDictionary headingDestinations) { + if (paragraph == null) { + return; + } + + if (paragraph.PageBreakBefore) { + pdf.PageBreak(); + } + + if (paragraph.IsPageBreak) { + pdf.PageBreak(); + return; + } + + RecordNativeBodyParagraphDiagnostics(paragraph, options, "body paragraph", mapsCheckBoxes: true, mapsFormFields: true, mapsPictureControls: true, mapsRepeatingSections: true); + IReadOnlyList checkboxControls = GetNativeCheckBoxControls(paragraph); + IReadOnlyList formFieldControls = GetNativeFormFieldControls(paragraph); + IReadOnlyList repeatingSectionControls = GetNativeRepeatingSectionControls(paragraph); + + if (!string.IsNullOrEmpty(paragraph.Bookmark?.Name)) { + pdf.Bookmark(paragraph.Bookmark!.Name!); + } + + WordTextBox? textBox = GetNativeParagraphTextBox(paragraph, out string? textBoxFallbackText); + if (textBox != null) { + RenderNativeTextBox(pdf, textBox, footnoteNumbersById, options, textBoxFallbackText); + return; + } + + if (paragraph.Shape != null) { + RenderNativeShape(pdf, paragraph.Shape); + } + + if (paragraph.Image != null) { + RenderNativeImage(pdf, paragraph.Image, MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false)); + } + + WordImage? pictureControlImage = paragraph.PictureControl?.Image; + if (pictureControlImage != null) { + RenderNativeImage(pdf, pictureControlImage, MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false)); + } + + List runs = GetNativeRuns(paragraph); + string content = paragraph.IsHyperLink && paragraph.Hyperlink != null ? paragraph.Hyperlink.Text : AppendNativeTextWithEquation(paragraph.Text, paragraph); + bool hasRenderableRuns = runs.Any(run => !run.IsImage && !string.IsNullOrEmpty(run.Text)); + List paragraphFootnoteNumbers = GetNativeParagraphFootnoteNumbers(paragraph, runs, footnoteNumbers, footnoteNumbersById); + PdfCore.PdfParagraphStyle style = CreateNativeParagraphStyle(paragraph); + if (marker == null && + paragraphFootnoteNumbers.Count == 0 && + IsNativeHorizontalRuleParagraph(paragraph, runs, content) && + CreateNativeHorizontalRuleStyle(paragraph, style) is { } horizontalRuleStyle) { + pdf.HR(style: horizontalRuleStyle); + return; + } + + if (!hasRenderableRuns && string.IsNullOrEmpty(content) && marker == null && paragraphFootnoteNumbers.Count == 0 && checkboxControls.Count == 0 && formFieldControls.Count == 0 && repeatingSectionControls.Count == 0) { + return; + } + + PdfCore.PdfAlign align = MapNativeParagraphAlign(paragraph.ParagraphAlignment); + PdfCore.PdfAlign objectAlign = MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false); + PdfCore.PdfColor? defaultColor = ParseNativeColor(paragraph.ColorHex); + int headingLevel = GetHeadingLevel(paragraph); + PdfCore.PdfColor? headingColor = GetNativeHeadingColor(headingLevel, defaultColor); + (string? LinkUri, string? LinkDestinationName, string? LinkContents) headingLink = GetNativeHeadingLink(paragraph); + bool hasHeadingLinkTarget = headingLink.LinkUri != null || headingLink.LinkDestinationName != null; + PdfCore.PdfHorizontalRuleStyle? topBorderRuleStyle = marker == null ? CreateNativeTopBorderRuleStyle(paragraph, style) : null; + PdfCore.PdfParagraphStyle paragraphStyle = topBorderRuleStyle == null ? style : style.Clone(); + if (topBorderRuleStyle != null) { + paragraphStyle.SpacingBefore = 0; + pdf.HR(style: topBorderRuleStyle); + } + + if (headingLevel > 0 && marker == null) { + if (paragraph._paragraph != null && + string.IsNullOrEmpty(paragraph.Bookmark?.Name) && + headingDestinations.TryGetValue(paragraph._paragraph, out string? generatedDestinationName)) { + pdf.Bookmark(generatedDestinationName); + } + + RenderNativeHeading(pdf, headingLevel, content, objectAlign, headingColor, headingLink.LinkUri, headingLink.LinkDestinationName, headingLink.LinkContents); + if (CreateNativeBottomBorderRuleStyle(paragraph, paragraphStyle) is { } headingRuleStyle) { + pdf.HR(style: headingRuleStyle); + } + + RenderNativeFormFields(pdf, formFieldControls, objectAlign); + RenderNativeCheckBoxes(pdf, checkboxControls, objectAlign); + RenderNativeRepeatingSections(pdf, repeatingSectionControls, align, defaultColor); + return; + } + + PdfCore.PanelStyle? panelStyle = CreateNativeParagraphPanelStyle(paragraph, paragraphStyle); + if (panelStyle != null) { + pdf.PanelParagraph(builder => { + AddNativeParagraphContent(builder, paragraph, marker, runs, hasRenderableRuns, content, paragraphFootnoteNumbers, options); + }, panelStyle, align, defaultColor); + RenderNativeFormFields(pdf, formFieldControls, objectAlign); + RenderNativeCheckBoxes(pdf, checkboxControls, objectAlign); + RenderNativeRepeatingSections(pdf, repeatingSectionControls, align, defaultColor); + return; + } + + PdfCore.PdfHorizontalRuleStyle? bottomBorderRuleStyle = marker == null ? CreateNativeBottomBorderRuleStyle(paragraph, paragraphStyle) : null; + if (bottomBorderRuleStyle != null && ReferenceEquals(paragraphStyle, style)) { + paragraphStyle = style.Clone(); + } + + if (bottomBorderRuleStyle != null) { + paragraphStyle.SpacingAfter = 0; + } + + if (hasRenderableRuns || !string.IsNullOrEmpty(content) || marker != null || paragraphFootnoteNumbers.Count > 0) { + pdf.Paragraph(builder => { + AddNativeParagraphContent(builder, paragraph, marker, runs, hasRenderableRuns, content, paragraphFootnoteNumbers, options); + }, align, defaultColor, paragraphStyle); + } + + if (bottomBorderRuleStyle != null) { + pdf.HR(style: bottomBorderRuleStyle); + } + + RenderNativeFormFields(pdf, formFieldControls, objectAlign); + RenderNativeCheckBoxes(pdf, checkboxControls, objectAlign); + RenderNativeRepeatingSections(pdf, repeatingSectionControls, align, defaultColor); + } + + private static void RenderNativeFormFields(INativePdfFlow pdf, IReadOnlyList formFieldControls, PdfCore.PdfAlign align) { + for (int index = 0; index < formFieldControls.Count; index++) { + W.SdtRun formField = formFieldControls[index]; + double spacingBefore = index == 0 ? 0D : 2D; + if (IsNativeDatePickerControl(formField)) { + pdf.TextField( + GetNativeContentControlFieldName(formField, index, "WordDatePicker"), + width: 150D, + height: 20D, + value: GetNativeDatePickerValue(formField), + align: align, + fontSize: 10D, + spacingBefore: spacingBefore, + spacingAfter: 4D); + continue; + } + + IReadOnlyList options = GetNativeChoiceFieldOptions(formField); + string? value = GetNativeChoiceFieldValue(formField, options); + if (options.Count == 0 || string.IsNullOrWhiteSpace(value)) { + continue; + } + + string fallbackPrefix = formField.SdtProperties?.Elements().Any() == true + ? "WordComboBox" + : "WordDropDownList"; + pdf.ChoiceField( + GetNativeContentControlFieldName(formField, index, fallbackPrefix), + options, + value, + width: 150D, + height: 20D, + align: align, + fontSize: 10D, + spacingBefore: spacingBefore, + spacingAfter: 4D, + isComboBox: true); + } + } + + private static void RenderNativeCheckBoxes(INativePdfFlow pdf, IReadOnlyList checkboxControls, PdfCore.PdfAlign align) { + for (int index = 0; index < checkboxControls.Count; index++) { + W.SdtRun checkbox = checkboxControls[index]; + pdf.CheckBox( + GetNativeCheckBoxFieldName(checkbox, index), + IsNativeCheckBoxChecked(checkbox), + size: 12D, + align: align, + spacingBefore: index == 0 ? 0D : 2D, + spacingAfter: 4D); + } + } + + private static void RenderNativeRepeatingSections(INativePdfFlow pdf, IReadOnlyList repeatingSectionControls, PdfCore.PdfAlign align, PdfCore.PdfColor? color) { + foreach (W.SdtRun repeatingSection in repeatingSectionControls) { + foreach (string itemText in GetNativeRepeatingSectionItems(repeatingSection)) { + if (string.IsNullOrWhiteSpace(itemText)) { + continue; + } + + pdf.Paragraph(builder => builder.Text(itemText), align, color); + } + } + } + + private static IReadOnlyList GetNativeRepeatingSectionItems(W.SdtRun repeatingSection) { + var items = new List(); + IEnumerable itemElements = repeatingSection.SdtContentRun?.ChildElements + .Where(element => element.LocalName == "repeatingSectionItem") ?? + Enumerable.Empty(); + + foreach (DocumentFormat.OpenXml.OpenXmlElement item in itemElements) { + string text = string.Concat(item.Descendants().Select(value => value.Text)); + if (!string.IsNullOrWhiteSpace(text)) { + items.Add(text); + } + } + + if (items.Count == 0) { + string text = GetNativeSdtText(repeatingSection) ?? string.Empty; + if (!string.IsNullOrWhiteSpace(text)) { + items.Add(text); + } + } + + return items; + } + + private static IReadOnlyList CreateNativeTableCellCheckBoxes(WordTableCell cell) { + var checkBoxes = new List(); + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + IReadOnlyList controls = GetNativeCheckBoxControls(paragraph); + for (int index = 0; index < controls.Count; index++) { + W.SdtRun checkbox = controls[index]; + checkBoxes.Add(new PdfCore.PdfTableCellCheckBox( + GetNativeCheckBoxFieldName(checkbox, checkBoxes.Count, "WordTableCheckBox"), + IsNativeCheckBoxChecked(checkbox), + size: 12D)); + } + } + + return checkBoxes; + } + + private static IReadOnlyList CreateNativeTableCellFormFields(WordTableCell cell) { + var formFields = new List(); + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + IReadOnlyList controls = GetNativeFormFieldControls(paragraph); + for (int index = 0; index < controls.Count; index++) { + W.SdtRun formField = controls[index]; + if (IsNativeDatePickerControl(formField)) { + formFields.Add(PdfCore.PdfTableCellFormField.TextField( + GetNativeContentControlFieldName(formField, formFields.Count, "WordTableDatePicker"), + GetNativeDatePickerValue(formField), + width: 150D, + height: 20D, + fontSize: 10D)); + continue; + } + + IReadOnlyList options = GetNativeChoiceFieldOptions(formField); + string? value = GetNativeChoiceFieldValue(formField, options); + if (options.Count == 0 || string.IsNullOrWhiteSpace(value)) { + continue; + } + + string fallbackPrefix = formField.SdtProperties?.Elements().Any() == true + ? "WordTableComboBox" + : "WordTableDropDownList"; + formFields.Add(PdfCore.PdfTableCellFormField.ChoiceField( + GetNativeContentControlFieldName(formField, formFields.Count, fallbackPrefix), + options, + value, + width: 150D, + height: 20D, + fontSize: 10D, + isComboBox: true)); + } + } + + return formFields; + } + + private static IReadOnlyList CreateNativeTableCellImages(WordTableCell cell) { + var images = new List(); + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + foreach (W.SdtRun pictureControl in GetNativePictureControls(paragraph)) { + var pictureParagraph = new WordParagraph(paragraph._document, paragraph._paragraph!, pictureControl); + WordImage? pictureControlImage = pictureParagraph.PictureControl?.Image; + if (pictureControlImage == null) { + continue; + } + + byte[] bytes = ImageEmbedder.GetImageBytes(pictureControlImage); + double width = pictureControlImage.Width.HasValue ? pictureControlImage.Width.Value * 72D / 96D : 144D; + double height = pictureControlImage.Height.HasValue ? pictureControlImage.Height.Value * 72D / 96D : 144D; + images.Add(new PdfCore.PdfTableCellImage(bytes, width, height)); + } + } + + return images; + } + + private static void AddNativeParagraphContent( + PdfCore.PdfParagraphBuilder builder, + WordParagraph paragraph, + (int Level, string Marker)? marker, + IReadOnlyList runs, + bool hasRenderableRuns, + string content, + IReadOnlyList paragraphFootnoteNumbers, + PdfSaveOptions? options) { + if (marker != null) { + builder.Text(new string(' ', Math.Max(0, marker.Value.Level - 1) * 2)); + builder.Text(marker.Value.Marker); + builder.Text(" "); + } + + IReadOnlyList tabStops = paragraph.TabStops; + int tabIndex = 0; + if (hasRenderableRuns) { + foreach (WordParagraph run in runs) { + if (run.IsImage && run.Image != null) { + continue; + } + + if (IsNativeTextWrappingBreak(run)) { + builder.LineBreak(); + tabIndex = 0; + continue; + } + + AddNativeRun(builder, run, paragraph, tabStops, ref tabIndex, options); + } + string? supplementalText = GetNativeSupplementalTextAfterRuns(content, runs); + if (!string.IsNullOrEmpty(supplementalText)) { + AddNativeText(builder, supplementalText!, paragraph, tabStops, ref tabIndex); + } + } else if (paragraph.IsHyperLink && paragraph.Hyperlink != null) { + ApplyNativeTextStyle(builder, paragraph); + AddNativeHyperLinkRun(builder, paragraph.Hyperlink.Text, paragraph.Hyperlink, tabStops, ref tabIndex); + ResetNativeTextStyle(builder); + } else { + AddNativeText(builder, content, paragraph, tabStops, ref tabIndex); + } + + AddNativeFootnoteReferences(builder, paragraphFootnoteNumbers); + } + + private static string? GetNativeSupplementalTextAfterRuns(string content, IReadOnlyList runs) { + if (string.IsNullOrEmpty(content)) { + return null; + } + + var renderedText = new StringBuilder(); + foreach (WordParagraph run in runs) { + if (run.IsImage || IsNativeTextWrappingBreak(run) || string.IsNullOrEmpty(run.Text)) { + continue; + } + + renderedText.Append(run.Text); + } + + if (renderedText.Length == 0) { + return content; + } + + string emittedText = renderedText.ToString(); + if (content.Length <= emittedText.Length || + !content.StartsWith(emittedText, StringComparison.Ordinal)) { + return null; + } + + return content.Substring(emittedText.Length); + } + + private static void AddNativeFootnoteReferences(PdfCore.PdfParagraphBuilder builder, IReadOnlyList footnoteNumbers) { + foreach (int footnoteNumber in footnoteNumbers) { + builder.Baseline(PdfCore.PdfTextBaseline.Superscript); + builder.Text(footnoteNumber.ToString(CultureInfo.InvariantCulture)); + builder.Baseline(PdfCore.PdfTextBaseline.Normal); + } + } + + private static bool IsNativeTextWrappingBreak(WordParagraph run) => + run.IsBreak && run.Break?.BreakType != W.BreakValues.Page; + + private static WordTextBox? GetNativeParagraphTextBox(WordParagraph paragraph, out string? fallbackText) { + fallbackText = GetNativeParagraphTextBoxPlainText(paragraph); + WordTextBox? textBox = paragraph.TextBox; + if (textBox != null || paragraph._paragraph == null) { + return textBox; + } + + foreach (W.Run run in paragraph._paragraph.Elements()) { + if (run.Descendants().Any() || + run.Descendants().Any()) { + return new WordTextBox(paragraph._document, paragraph._paragraph, run); + } + } + + return null; + } + + private static string? GetNativeParagraphTextBoxPlainText(WordParagraph paragraph) { + if (paragraph._paragraph == null) { + return null; + } + + var parts = new List(); + foreach (Wps.TextBoxInfo2 textBoxInfo in paragraph._paragraph.Descendants()) { + parts.AddRange(textBoxInfo.Descendants().Select(text => text.Text)); + } + + foreach (DocumentFormat.OpenXml.Vml.TextBox textBox in paragraph._paragraph.Descendants()) { + parts.AddRange(textBox.Descendants().Select(text => text.Text)); + } + + string textBoxText = string.Concat(parts); + return string.IsNullOrWhiteSpace(textBoxText) ? null : textBoxText; + } + + private static void RenderNativeTextBox(INativePdfFlow pdf, WordTextBox textBox, Dictionary footnoteNumbersById, PdfSaveOptions? options, string? fallbackText = null) { + if (!string.IsNullOrWhiteSpace(fallbackText)) { + PdfCore.PanelStyle fallbackStyle = CreateNativeTextBoxPanelStyle(textBox); + pdf.PanelParagraph(builder => builder.Text(fallbackText!), fallbackStyle, PdfCore.PdfAlign.Left); + return; + } + + IReadOnlyList paragraphs = GetNativeTextBoxParagraphs(textBox); + if (paragraphs.Count == 0) { + return; + } + + PdfCore.PanelStyle style = CreateNativeTextBoxPanelStyle(textBox); + PdfCore.PdfAlign defaultTextAlign = MapNativeTextBoxTextAlign(paragraphs); + pdf.PanelParagraph(builder => { + for (int index = 0; index < paragraphs.Count; index++) { + WordParagraph paragraph = paragraphs[index]; + if (index > 0) { + builder.LineBreak(); + } + + List runs = GetNativeRuns(paragraph); + string content = paragraph.IsHyperLink && paragraph.Hyperlink != null ? paragraph.Hyperlink.Text : paragraph.Text; + bool hasRenderableRuns = runs.Any(run => !run.IsImage && !string.IsNullOrEmpty(run.Text)); + List paragraphFootnoteNumbers = GetNativeParagraphFootnoteNumbers(paragraph, runs, Array.Empty(), footnoteNumbersById); + AddNativeParagraphContent(builder, paragraph, null, runs, hasRenderableRuns, content, paragraphFootnoteNumbers, options); + } + }, style, defaultTextAlign); + } + + private static IReadOnlyList GetNativeTextBoxParagraphs(WordTextBox textBox) { + IReadOnlyList directParagraphs = textBox.Paragraphs; + if (HasNativeRenderableTextBoxText(directParagraphs)) { + return directParagraphs; + } + + IReadOnlyList elementParagraphs = CollapseNativeParagraphElements(textBox.Elements) + .OfType() + .ToList(); + return elementParagraphs; + } + + private static bool HasNativeRenderableTextBoxText(IEnumerable paragraphs) { + foreach (WordParagraph paragraph in paragraphs) { + if (!string.IsNullOrWhiteSpace(paragraph.Text)) { + return true; + } + + if (GetNativeRuns(paragraph).Any(run => !run.IsImage && !string.IsNullOrWhiteSpace(run.Text))) { + return true; + } + } + + return false; + } + + private static PdfCore.PanelStyle CreateNativeTextBoxPanelStyle(WordTextBox textBox) { + var style = new PdfCore.PanelStyle { + BorderColor = PdfCore.PdfColor.Black, + BorderWidth = 0.75D, + PaddingX = 6D, + PaddingY = 4D, + SpacingAfter = 6D, + Align = MapNativeTextBoxBoxAlign(textBox.HorizontalAlignment) + }; + + double maxWidth = ConvertNativeEmusToPoints(textBox.Width); + if (maxWidth > 0D) { + style.MaxWidth = maxWidth; + } + + return style; + } + + private static PdfCore.PdfAlign MapNativeTextBoxBoxAlign(WordHorizontalAlignmentValues alignment) { + switch (alignment) { + case WordHorizontalAlignmentValues.Center: + return PdfCore.PdfAlign.Center; + case WordHorizontalAlignmentValues.Right: + case WordHorizontalAlignmentValues.Outside: + return PdfCore.PdfAlign.Right; + default: + return PdfCore.PdfAlign.Left; + } + } + + private static PdfCore.PdfAlign MapNativeTextBoxTextAlign(IReadOnlyList paragraphs) { + foreach (WordParagraph paragraph in paragraphs) { + if (!string.IsNullOrEmpty(paragraph.Text)) { + return MapNativeParagraphAlign(paragraph.ParagraphAlignment); + } + } + + return PdfCore.PdfAlign.Left; + } + + private static void RenderNativeHeading(INativePdfFlow pdf, int level, string text, PdfCore.PdfAlign align, PdfCore.PdfColor? color, string? linkUri = null, string? linkDestinationName = null, string? linkContents = null) { + PdfCore.PdfHeadingStyle style = CreateNativeWordHeadingStyle(level); + pdf.Heading(level, text, align, color, style, linkUri, linkDestinationName, linkContents); + } + + private static PdfCore.PdfHeadingStyle CreateNativeWordHeadingStyle(int level) { + double fontSize = level switch { + 1 => 16D, + 2 => 13D, + _ => 12D + }; + + return new PdfCore.PdfHeadingStyle { + FontSize = fontSize, + LineHeight = 1.18D, + SpacingBefore = level == 1 ? 24D : 10D, + SpacingAfter = level == 1 ? 5D : 4D, + Bold = false, + ApplySpacingBeforeAtTop = true, + KeepWithNext = true + }; + } + + private static (string? LinkUri, string? LinkDestinationName, string? LinkContents) GetNativeHeadingLink(WordParagraph paragraph) { + if (!paragraph.IsHyperLink || paragraph.Hyperlink == null) { + return (null, null, null); + } + + string? contents = string.IsNullOrWhiteSpace(paragraph.Hyperlink.Tooltip) + ? paragraph.Hyperlink.Text + : paragraph.Hyperlink.Tooltip; + if (string.IsNullOrWhiteSpace(contents)) { + contents = null; + } + + Uri? uri = paragraph.Hyperlink.Uri; + if (uri != null && uri.IsAbsoluteUri) { + return (uri.AbsoluteUri, null, contents); + } + + string? bookmarkName = paragraph.Hyperlink.Anchor; + if (!string.IsNullOrWhiteSpace(bookmarkName)) { + return (null, bookmarkName, contents); + } + + return (null, null, null); + } + + private static void AddNativeRun( + PdfCore.PdfParagraphBuilder builder, + WordParagraph run, + WordParagraph paragraphStyleFallback, + IReadOnlyList tabStops, + ref int tabIndex, + PdfSaveOptions? options) { + if (string.IsNullOrEmpty(run.Text)) { + return; + } + + ApplyNativeTextStyle(builder, run, paragraphStyleFallback); + + if (run.IsHyperLink && run.Hyperlink != null) { + AddNativeHyperLinkRun(builder, run.Text, run.Hyperlink, tabStops, ref tabIndex); + } else { + AddNativeRunText(builder, run.Text, tabStops, ref tabIndex); + } + + ResetNativeTextStyle(builder); + } + + private static void AddNativeText( + PdfCore.PdfParagraphBuilder builder, + string text, + WordParagraph paragraph, + IReadOnlyList tabStops, + ref int tabIndex) { + ApplyNativeTextStyle(builder, paragraph); + AddNativeRunText(builder, text, tabStops, ref tabIndex); + ResetNativeTextStyle(builder); + } + + private static void ApplyNativeTextStyle(PdfCore.PdfParagraphBuilder builder, WordParagraph paragraph, WordParagraph? fallback = null) { + builder.Bold(paragraph.Bold); + builder.Italic(paragraph.Italic); + builder.Underline(paragraph.Underline != null); + builder.Strike(paragraph.Strike || paragraph.DoubleStrike); + builder.Baseline(GetNativeTextBaseline(paragraph)); + if (paragraph.FontSize.HasValue && paragraph.FontSize.Value > 0) { + builder.FontSize(paragraph.FontSize.Value); + } + + if (TryMapNativeFontFamily(paragraph.FontFamily, out PdfCore.PdfStandardFont font) || + TryMapNativeFontFamily(paragraph.FontFamilyHighAnsi, out font) || + TryMapNativeFontFamily(paragraph.FontFamilyEastAsia, out font) || + TryMapNativeFontFamily(paragraph.FontFamilyComplexScript, out font) || + (fallback != null && ( + TryMapNativeFontFamily(fallback.FontFamily, out font) || + TryMapNativeFontFamily(fallback.FontFamilyHighAnsi, out font) || + TryMapNativeFontFamily(fallback.FontFamilyEastAsia, out font) || + TryMapNativeFontFamily(fallback.FontFamilyComplexScript, out font)))) { + builder.Font(font); + } + + PdfCore.PdfColor? color = ParseNativeColor(paragraph.ColorHex); + PdfCore.PdfColor? background = MapNativeHighlight(paragraph.Highlight); + if (color.HasValue) { + builder.Color(color.Value); + } + + if (background.HasValue) { + builder.BackgroundColor(background.Value); + } + } + + private static void ResetNativeTextStyle(PdfCore.PdfParagraphBuilder builder) { + builder.Bold(false) + .Italic(false) + .Underline(false) + .Strike(false) + .Baseline(PdfCore.PdfTextBaseline.Normal) + .ResetColor() + .ResetFontSize() + .ResetFont() + .ResetBackgroundColor(); + } + + private static void AddNativeRunText(PdfCore.PdfParagraphBuilder builder, string text, IReadOnlyList tabStops, ref int tabIndex) { + int currentTabIndex = tabIndex; + AddNativeTextSegments( + text, + value => builder.Text(value), + () => builder.LineBreak(), + () => { + AddNativeTab(builder, tabStops, currentTabIndex); + currentTabIndex++; + }, + () => currentTabIndex = 0); + tabIndex = currentTabIndex; + } + + private static void AddNativeTextSegments(string text, Action addText, Action addLineBreak, Action addTab, Action resetTabs) { + if (string.IsNullOrEmpty(text)) { + return; + } + + var buffer = new StringBuilder(); + for (int index = 0; index < text.Length; index++) { + char ch = text[index]; + if (ch == '\r') { + if (index + 1 < text.Length && text[index + 1] == '\n') { + continue; + } + + Flush(); + addLineBreak(); + resetTabs(); + continue; + } + + if (ch == '\n') { + Flush(); + addLineBreak(); + resetTabs(); + continue; + } + + if (ch == '\t') { + Flush(); + addTab(); + continue; + } + + buffer.Append(ch); + } + + Flush(); + + void Flush() { + if (buffer.Length == 0) { + return; + } + + addText(buffer.ToString()); + buffer.Length = 0; + } + } + + private static void AddNativeTab(PdfCore.PdfParagraphBuilder builder, IReadOnlyList tabStops, int tabIndex) { + if (tabIndex < tabStops.Count) { + WordTabStop tabStop = tabStops[tabIndex]; + builder.Tab(MapNativeTabLeader(tabStop.Leader), MapNativeTabAlignment(tabStop.Alignment)); + return; + } + + builder.Tab(); + } + + private static void AddNativeHyperLinkRun(PdfCore.PdfParagraphBuilder builder, string text, WordHyperLink hyperlink, IReadOnlyList tabStops, ref int tabIndex) { + Uri? uri = hyperlink.Uri; + string? linkUri = uri != null && uri.IsAbsoluteUri ? uri.AbsoluteUri : null; + string? bookmarkName = string.IsNullOrWhiteSpace(hyperlink.Anchor) ? null : hyperlink.Anchor; + if (linkUri == null && bookmarkName == null) { + AddNativeRunText(builder, text, tabStops, ref tabIndex); + return; + } + + string? contents = GetNativeHyperLinkContents(hyperlink); + int currentTabIndex = tabIndex; + AddNativeTextSegments( + text, + value => { + if (linkUri != null) { + builder.Link(value, linkUri, contents: contents); + } else { + builder.LinkToBookmark(value, bookmarkName!, contents: contents); + } + }, + () => builder.LineBreak(), + () => { + AddNativeTab(builder, tabStops, currentTabIndex); + currentTabIndex++; + }, + () => currentTabIndex = 0); + tabIndex = currentTabIndex; + } + + private static string? GetNativeHyperLinkContents(WordHyperLink hyperlink) => + string.IsNullOrWhiteSpace(hyperlink.Tooltip) ? null : hyperlink.Tooltip; + + private static void AddNativeHyperLinkRun(PdfCore.PdfParagraphBuilder builder, WordHyperLink hyperlink) { + int tabIndex = 0; + AddNativeHyperLinkRun(builder, hyperlink.Text, hyperlink, Array.Empty(), ref tabIndex); + } + + private static void RenderNativeHyperLink(INativePdfFlow pdf, WordHyperLink link) { + if (link == null || string.IsNullOrEmpty(link.Text)) { + return; + } + + pdf.Paragraph(builder => AddNativeHyperLinkRun(builder, link)); + } + + private static void RenderNativeShape(INativePdfFlow pdf, WordShape shape) { + OfficeShape? nativeShape = CreateNativeShape(shape); + if (nativeShape == null) { + return; + } + + pdf.Shape(nativeShape, PdfCore.PdfAlign.Left, spacingAfter: 6); + } + + private static OfficeShape? CreateNativeShape(WordShape shape) { + if (shape == null || shape.Hidden == true) { + return null; + } + + OfficeShape? nativeShape; + if (shape.Line != null) { + (double x1, double y1) = ParseNativeShapePoint(shape.Line.From?.Value ?? "0pt,0pt"); + (double x2, double y2) = ParseNativeShapePoint(shape.Line.To?.Value ?? "0pt,0pt"); + nativeShape = OfficeShape.Line(x1, y1, x2, y2); + } else if (shape._polygon != null && TryCreateNativePolygonShape(shape._polygon.Points?.Value, out nativeShape)) { + } else { + (double Width, double Height)? dimensions = GetNativeShapeDimensions(shape); + if (!dimensions.HasValue) { + return null; + } + + double width = dimensions.Value.Width; + double height = dimensions.Value.Height; + if (shape._ellipse != null) { + nativeShape = OfficeShape.Ellipse(width, height); + } else if (shape._roundRectangle != null) { + double arcSize = shape.ArcSize ?? 0.25D; + double cornerRadius = Math.Min(width, height) * Math.Max(0D, Math.Min(1D, arcSize)) / 2D; + nativeShape = OfficeShape.RoundedRectangle(width, height, cornerRadius); + } else if (TryGetNativeDrawingPreset(shape, out A.ShapeTypeValues preset)) { + nativeShape = CreateNativeDrawingPresetShape(preset, width, height); + if (nativeShape == null) { + return null; + } + } else { + nativeShape = OfficeShape.Rectangle(width, height); + } + } + + if (nativeShape == null) { + return null; + } + + ApplyNativeShapeStyle(nativeShape, shape); + return nativeShape; + } + + private static (double Width, double Height)? GetNativeShapeDimensions(WordShape shape) { + double width = shape.Width; + double height = shape.Height; + if (width > 0 && height > 0) { + return (width, height); + } + + A.Extents? extents = shape._wpsShape? + .GetFirstChild()? + .GetFirstChild()? + .Extents; + + long? cx = extents?.Cx?.Value; + long? cy = extents?.Cy?.Value; + if (!cx.HasValue || !cy.HasValue || cx.Value <= 0 || cy.Value <= 0) { + return null; + } + + return (ConvertNativeEmusToPoints(cx.Value), ConvertNativeEmusToPoints(cy.Value)); + } + + private static bool TryGetNativeDrawingPreset(WordShape shape, out A.ShapeTypeValues preset) { + A.PresetGeometry? geometry = shape._wpsShape? + .GetFirstChild()? + .GetFirstChild(); + if (geometry?.Preset?.Value is A.ShapeTypeValues value) { + preset = value; + return true; + } + + preset = default; + return false; + } + + private static OfficeShape? CreateNativeDrawingPresetShape(A.ShapeTypeValues preset, double width, double height) { + if (preset == A.ShapeTypeValues.Line) { + return OfficeShape.Line(0, height / 2D, width, height / 2D); + } + + if (preset == A.ShapeTypeValues.Ellipse) { + return OfficeShape.Ellipse(width, height); + } + + if (preset == A.ShapeTypeValues.RoundRectangle) { + return OfficeShape.RoundedRectangle(width, height, Math.Min(width, height) / 6D); + } + + if (preset == A.ShapeTypeValues.Triangle) { + return OfficeShape.Polygon( + new OfficePoint(width / 2D, 0), + new OfficePoint(width, height), + new OfficePoint(0, height)); + } + + if (preset == A.ShapeTypeValues.Diamond) { + return OfficeShape.Polygon( + new OfficePoint(width / 2D, 0), + new OfficePoint(width, height / 2D), + new OfficePoint(width / 2D, height), + new OfficePoint(0, height / 2D)); + } + + if (preset == A.ShapeTypeValues.Pentagon) { + return CreateRegularNativePolygon(5, width, height, -90D); + } + + if (preset == A.ShapeTypeValues.Hexagon) { + return OfficeShape.Polygon( + new OfficePoint(width * 0.25D, 0), + new OfficePoint(width * 0.75D, 0), + new OfficePoint(width, height / 2D), + new OfficePoint(width * 0.75D, height), + new OfficePoint(width * 0.25D, height), + new OfficePoint(0, height / 2D)); + } + + if (preset == A.ShapeTypeValues.RightArrow) { + return OfficeShape.Polygon( + new OfficePoint(0, height * 0.25D), + new OfficePoint(width * 0.6D, height * 0.25D), + new OfficePoint(width * 0.6D, 0), + new OfficePoint(width, height / 2D), + new OfficePoint(width * 0.6D, height), + new OfficePoint(width * 0.6D, height * 0.75D), + new OfficePoint(0, height * 0.75D)); + } + + if (preset == A.ShapeTypeValues.LeftArrow) { + return OfficeShape.Polygon( + new OfficePoint(width, height * 0.25D), + new OfficePoint(width * 0.4D, height * 0.25D), + new OfficePoint(width * 0.4D, 0), + new OfficePoint(0, height / 2D), + new OfficePoint(width * 0.4D, height), + new OfficePoint(width * 0.4D, height * 0.75D), + new OfficePoint(width, height * 0.75D)); + } + + if (preset == A.ShapeTypeValues.UpArrow) { + return OfficeShape.Polygon( + new OfficePoint(width * 0.25D, height), + new OfficePoint(width * 0.25D, height * 0.4D), + new OfficePoint(0, height * 0.4D), + new OfficePoint(width / 2D, 0), + new OfficePoint(width, height * 0.4D), + new OfficePoint(width * 0.75D, height * 0.4D), + new OfficePoint(width * 0.75D, height)); + } + + if (preset == A.ShapeTypeValues.DownArrow) { + return OfficeShape.Polygon( + new OfficePoint(width * 0.25D, 0), + new OfficePoint(width * 0.25D, height * 0.6D), + new OfficePoint(0, height * 0.6D), + new OfficePoint(width / 2D, height), + new OfficePoint(width, height * 0.6D), + new OfficePoint(width * 0.75D, height * 0.6D), + new OfficePoint(width * 0.75D, 0)); + } + + if (preset == A.ShapeTypeValues.Star5) { + return CreateNativeStar5(width, height); + } + + if (preset == A.ShapeTypeValues.Heart) { + return CreateNativeHeart(width, height); + } + + if (preset == A.ShapeTypeValues.Cloud) { + return CreateNativeCloud(width, height); + } + + if (preset == A.ShapeTypeValues.Donut) { + return CreateNativeDonut(width, height); + } + + if (preset == A.ShapeTypeValues.Can) { + return CreateNativeCan(width, height); + } + + if (preset == A.ShapeTypeValues.Cube) { + return CreateNativeCube(width, height); + } + + if (preset == A.ShapeTypeValues.Rectangle) { + return OfficeShape.Rectangle(width, height); + } + + return null; + } + + private static OfficeShape CreateRegularNativePolygon(int sides, double width, double height, double startAngleDegrees) { + var points = new OfficePoint[sides]; + double centerX = width / 2D; + double centerY = height / 2D; + double radiusX = width / 2D; + double radiusY = height / 2D; + for (int i = 0; i < sides; i++) { + double angle = (startAngleDegrees + (360D * i / sides)) * Math.PI / 180D; + points[i] = new OfficePoint(centerX + radiusX * Math.Cos(angle), centerY + radiusY * Math.Sin(angle)); + } + + return OfficeShape.Polygon(points); + } + + private static OfficeShape CreateNativeHeart(double width, double height) => + OfficeShape.Path( + OfficePathCommand.MoveTo(width * 0.5D, height), + OfficePathCommand.CubicBezierTo(width * 0.18D, height * 0.72D, 0, height * 0.52D, 0, height * 0.28D), + OfficePathCommand.CubicBezierTo(0, height * 0.08D, width * 0.16D, 0, width * 0.31D, 0), + OfficePathCommand.CubicBezierTo(width * 0.42D, 0, width * 0.49D, height * 0.07D, width * 0.5D, height * 0.18D), + OfficePathCommand.CubicBezierTo(width * 0.51D, height * 0.07D, width * 0.58D, 0, width * 0.69D, 0), + OfficePathCommand.CubicBezierTo(width * 0.84D, 0, width, height * 0.08D, width, height * 0.28D), + OfficePathCommand.CubicBezierTo(width, height * 0.52D, width * 0.82D, height * 0.72D, width * 0.5D, height), + OfficePathCommand.Close()); + + private static OfficeShape CreateNativeCloud(double width, double height) => + OfficeShape.Path( + OfficePathCommand.MoveTo(width * 0.18D, height * 0.7D), + OfficePathCommand.CubicBezierTo(width * 0.05D, height * 0.7D, 0, height * 0.58D, width * 0.09D, height * 0.48D), + OfficePathCommand.CubicBezierTo(width * 0.03D, height * 0.32D, width * 0.19D, height * 0.18D, width * 0.34D, height * 0.26D), + OfficePathCommand.CubicBezierTo(width * 0.42D, height * 0.04D, width * 0.72D, height * 0.08D, width * 0.75D, height * 0.32D), + OfficePathCommand.CubicBezierTo(width * 0.94D, height * 0.27D, width, height * 0.46D, width * 0.91D, height * 0.61D), + OfficePathCommand.CubicBezierTo(width * 0.84D, height * 0.75D, width * 0.63D, height * 0.76D, width * 0.54D, height * 0.68D), + OfficePathCommand.CubicBezierTo(width * 0.46D, height * 0.82D, width * 0.25D, height * 0.82D, width * 0.18D, height * 0.7D), + OfficePathCommand.Close()); + + private static OfficeShape CreateNativeDonut(double width, double height) { + List commands = CreateNativeEllipsePath(width / 2D, height / 2D, width / 2D, height / 2D, clockwise: true); + commands.AddRange(CreateNativeEllipsePath(width / 2D, height / 2D, width * 0.22D, height * 0.22D, clockwise: false)); + return OfficeShape.Path(commands); + } + + private static OfficeShape CreateNativeCan(double width, double height) { + double topY = height * 0.18D; + double bottomY = height * 0.82D; + double rx = width / 2D; + double ry = height * 0.14D; + double k = 0.5522847498307936D; + return OfficeShape.Path( + OfficePathCommand.MoveTo(0, topY), + OfficePathCommand.CubicBezierTo(0, topY - ry * k, rx - rx * k, topY - ry, rx, topY - ry), + OfficePathCommand.CubicBezierTo(rx + rx * k, topY - ry, width, topY - ry * k, width, topY), + OfficePathCommand.LineTo(width, bottomY), + OfficePathCommand.CubicBezierTo(width, bottomY + ry * k, rx + rx * k, bottomY + ry, rx, bottomY + ry), + OfficePathCommand.CubicBezierTo(rx - rx * k, bottomY + ry, 0, bottomY + ry * k, 0, bottomY), + OfficePathCommand.Close()); + } + + private static OfficeShape CreateNativeCube(double width, double height) => + OfficeShape.Polygon( + new OfficePoint(width * 0.32D, 0), + new OfficePoint(width, height * 0.18D), + new OfficePoint(width, height * 0.72D), + new OfficePoint(width * 0.62D, height), + new OfficePoint(0, height * 0.82D), + new OfficePoint(0, height * 0.28D)); + + private static List CreateNativeEllipsePath(double centerX, double centerY, double radiusX, double radiusY, bool clockwise) { + double k = 0.5522847498307936D; + if (clockwise) { + return new List { + OfficePathCommand.MoveTo(centerX + radiusX, centerY), + OfficePathCommand.CubicBezierTo(centerX + radiusX, centerY + radiusY * k, centerX + radiusX * k, centerY + radiusY, centerX, centerY + radiusY), + OfficePathCommand.CubicBezierTo(centerX - radiusX * k, centerY + radiusY, centerX - radiusX, centerY + radiusY * k, centerX - radiusX, centerY), + OfficePathCommand.CubicBezierTo(centerX - radiusX, centerY - radiusY * k, centerX - radiusX * k, centerY - radiusY, centerX, centerY - radiusY), + OfficePathCommand.CubicBezierTo(centerX + radiusX * k, centerY - radiusY, centerX + radiusX, centerY - radiusY * k, centerX + radiusX, centerY), + OfficePathCommand.Close() + }; + } + + return new List { + OfficePathCommand.MoveTo(centerX + radiusX, centerY), + OfficePathCommand.CubicBezierTo(centerX + radiusX, centerY - radiusY * k, centerX + radiusX * k, centerY - radiusY, centerX, centerY - radiusY), + OfficePathCommand.CubicBezierTo(centerX - radiusX * k, centerY - radiusY, centerX - radiusX, centerY - radiusY * k, centerX - radiusX, centerY), + OfficePathCommand.CubicBezierTo(centerX - radiusX, centerY + radiusY * k, centerX - radiusX * k, centerY + radiusY, centerX, centerY + radiusY), + OfficePathCommand.CubicBezierTo(centerX + radiusX * k, centerY + radiusY, centerX + radiusX, centerY + radiusY * k, centerX + radiusX, centerY), + OfficePathCommand.Close() + }; + } + + private static OfficeShape CreateNativeStar5(double width, double height) { + var points = new OfficePoint[10]; + double centerX = width / 2D; + double centerY = height / 2D; + double outerX = width / 2D; + double outerY = height / 2D; + double innerX = outerX * 0.45D; + double innerY = outerY * 0.45D; + for (int i = 0; i < points.Length; i++) { + bool outer = i % 2 == 0; + double angle = (-90D + 36D * i) * Math.PI / 180D; + double radiusX = outer ? outerX : innerX; + double radiusY = outer ? outerY : innerY; + points[i] = new OfficePoint(centerX + radiusX * Math.Cos(angle), centerY + radiusY * Math.Sin(angle)); + } + + return OfficeShape.Polygon(points); + } + + private static bool TryCreateNativePolygonShape(string? pointsText, out OfficeShape? shape) { + shape = null; + if (string.IsNullOrWhiteSpace(pointsText)) { + return false; + } + + string text = pointsText!; + var points = new List(); + foreach (string token in text.Split(new[] { ' ', ';' }, StringSplitOptions.RemoveEmptyEntries)) { + string[] parts = token.Split(','); + if (parts.Length != 2 || + !double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double x) || + !double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double y)) { + return false; + } + + points.Add(new OfficePoint(x, y)); + } + + if (points.Count < 3) { + return false; + } + + shape = OfficeShape.Polygon(points); + return true; + } + + private static void ApplyNativeShapeStyle(OfficeShape nativeShape, WordShape wordShape) { + if (nativeShape.Kind != OfficeShapeKind.Line) { + PdfCore.PdfColor? fill = ParseNativeColor(wordShape.FillColorHex); + if (fill.HasValue) { + nativeShape.FillColor = fill.Value.ToOfficeColor(); + } + } + + bool drawStroke = nativeShape.Kind == OfficeShapeKind.Line || + wordShape.Stroked == true || + (wordShape._wpsShape != null && !string.IsNullOrWhiteSpace(wordShape.StrokeColorHex)); + if (!drawStroke) { + nativeShape.StrokeColor = null; + nativeShape.StrokeWidth = 0; + return; + } + + PdfCore.PdfColor? stroke = ParseNativeColor(wordShape.StrokeColorHex); + nativeShape.StrokeColor = (stroke ?? PdfCore.PdfColor.Black).ToOfficeColor(); + nativeShape.StrokeWidth = Math.Max(0D, wordShape.StrokeWeight ?? 1D); + } + + private static (double X, double Y) ParseNativeShapePoint(string value) { + string[] parts = value.Split(','); + if (parts.Length != 2) { + return (0D, 0D); + } + + return (ParseNativeShapePointPart(parts[0]), ParseNativeShapePointPart(parts[1])); + } + + private static double ParseNativeShapePointPart(string value) { + string normalized = value.Trim().Replace("pt", string.Empty); + return double.TryParse(normalized, NumberStyles.Float, CultureInfo.InvariantCulture, out double result) ? result : 0D; + } + + private static void RenderNativeTable(INativePdfFlow pdf, WordTable table, Func getMarker, Dictionary footnoteNumbersById, PdfSaveOptions? options) { + RecordNativeBodyTableDiagnostics(table, options, "body table"); + + TableLayout layout = TableLayoutCache.GetLayout(table); + var rows = new List(); + var cellFills = new Dictionary<(int Row, int Column), PdfCore.PdfColor>(); + var cellBorders = new Dictionary<(int Row, int Column), PdfCore.PdfCellBorder>(); + var cellPaddings = new Dictionary<(int Row, int Column), PdfCore.PdfCellPadding>(); + var cellAlignments = new Dictionary<(int Row, int Column), PdfCore.PdfColumnAlign>(); + var cellVerticalAlignments = new Dictionary<(int Row, int Column), PdfCore.PdfCellVerticalAlign>(); + var horizontalAlignments = CreateNativeTableHorizontalAlignments(layout); + var verticalAlignments = CreateNativeTableVerticalAlignments(layout); + for (int rowIndex = 0; rowIndex < layout.Rows.Count; rowIndex++) { + IReadOnlyList row = layout.Rows[rowIndex]; + var nativeCells = new List(); + int logicalColumnIndex = 0; + for (int columnIndex = 0; columnIndex < row.Count; columnIndex++) { + WordTableCell cell = row[columnIndex]; + if (IsNativeHorizontalMergeContinuation(cell)) { + continue; + } + + int columnSpan = GetNativeCellColumnSpan(cell); + if (IsNativeVerticalMergeContinuation(cell)) { + logicalColumnIndex += columnSpan; + continue; + } + + IReadOnlyList cellRuns = CreateNativeCellRuns(cell, footnoteNumbersById); + IReadOnlyList checkBoxes = CreateNativeTableCellCheckBoxes(cell); + IReadOnlyList formFields = CreateNativeTableCellFormFields(cell); + IReadOnlyList images = CreateNativeTableCellImages(cell); + (string? LinkUri, string? LinkContents) link = GetNativeCellLink(cell); + int rowSpan = GetNativeCellRowSpan(cell); + nativeCells.Add(new PdfCore.PdfTableCell( + cellRuns, + columnSpan, + link.LinkUri, + link.LinkContents, + rowSpan, + checkBoxes.Count == 0 ? null : checkBoxes, + formFields.Count == 0 ? null : formFields, + images.Count == 0 ? null : images)); + + PdfCore.PdfColor? fill = ParseNativeColor(cell.ShadingFillColorHex); + if (fill.HasValue) { + cellFills[(rowIndex, logicalColumnIndex)] = fill.Value; + } + + PdfCore.PdfCellBorder? border = CreateNativeTableCellBorder(cell.Borders); + if (border != null) { + cellBorders[(rowIndex, logicalColumnIndex)] = border; + } + + PdfCore.PdfCellPadding? padding = CreateNativeTableCellPadding(cell); + if (padding != null) { + cellPaddings[(rowIndex, logicalColumnIndex)] = padding; + } + + PdfCore.PdfColumnAlign cellAlignment = GetNativeCellHorizontalAlignment(cell); + if (cellAlignment != PdfCore.PdfColumnAlign.Left) { + cellAlignments[(rowIndex, logicalColumnIndex)] = cellAlignment; + } + + PdfCore.PdfCellVerticalAlign cellVerticalAlignment = MapNativeCellVerticalAlign(cell.VerticalAlignment); + if (cellVerticalAlignment != PdfCore.PdfCellVerticalAlign.Top) { + cellVerticalAlignments[(rowIndex, logicalColumnIndex)] = cellVerticalAlignment; + } + + logicalColumnIndex += columnSpan; + } + + rows.Add(nativeCells.ToArray()); + } + + if (rows.Count == 0) { + return; + } + + PdfCore.PdfTableStyle style = CreateNativeTableStyle(table, rows.Count, options); + if (cellFills.Count > 0) { + style.CellFills = cellFills; + } + + if (cellBorders.Count > 0) { + style.CellBorders = cellBorders; + } + + if (cellPaddings.Count > 0) { + style.CellPaddings = cellPaddings; + } + + if (cellAlignments.Count > 0) { + style.CellAlignments = cellAlignments; + } + + if (cellVerticalAlignments.Count > 0) { + style.CellVerticalAlignments = cellVerticalAlignments; + } + + style.ColumnWidthPoints = CreateNativeColumnWidthPoints(layout, style); + + if (horizontalAlignments != null) { + style.Alignments = horizontalAlignments; + } + + if (verticalAlignments != null) { + style.VerticalAlignments = verticalAlignments; + } + + pdf.Table(rows, MapNativeTableAlignment(table.Alignment), style); + } + + private static List? CreateNativeColumnWidthPoints(TableLayout layout, PdfCore.PdfTableStyle style) { + if (style.AutoFitColumns || layout.ColumnWidths.Length == 0 || !layout.ColumnWidths.All(width => width > 0)) { + return null; + } + + var widths = layout.ColumnWidths.Select(width => (double)width).ToList(); + double totalWidth = widths.Sum(); + if (style.MaxWidth.HasValue && totalWidth > style.MaxWidth.Value + 0.001D) { + double scale = style.MaxWidth.Value / totalWidth; + for (int i = 0; i < widths.Count; i++) { + widths[i] *= scale; + } + } + + return widths.Select(width => (double?)width).ToList(); + } + + private static PdfCore.PdfTableStyle CreateNativeTableStyle(WordTable table, int rowCount, PdfSaveOptions? options) { + PdfCore.PdfTableStyle style = ResolveNativeWordTableStyle(table) ?? new PdfCore.PdfTableStyle { + RowStripeFill = null + }; + style.FontSize ??= 10D; + style.LineHeight ??= 1.15D; + + int repeatedHeaderRowCount = GetNativeTableRepeatedHeaderRowCount(table, rowCount); + style.HeaderRowCount = GetNativeTableVisualHeaderRowCount(table, rowCount, repeatedHeaderRowCount); + style.RepeatHeaderRowCount = repeatedHeaderRowCount; + if (options?.DefaultTableBorders == true && style.BorderColor == null) { + style.BorderColor = PdfCore.PdfColor.LightGray; + } + + ApplyNativeTableBorders(table, style); + ApplyNativeTableDefaultCellMargins(table, style); + ApplyNativeTableLayoutOptions(table, style); + ApplyNativeTableRowOptions(table, style); + return style; + } + + private static void ApplyNativeTableLayoutOptions(WordTable table, PdfCore.PdfTableStyle style) { + W.TableProperties? properties = table._tableProperties; + if (IsNativeTableAutoFitToContents(properties)) { + style.AutoFitColumns = true; + } + + double? maxWidth = GetNativeTablePreferredWidth(properties?.TableWidth); + if (maxWidth.HasValue) { + style.MaxWidth = maxWidth.Value; + } + + double? leftIndent = GetNativeTableLeftIndent(properties?.TableIndentation); + if (leftIndent.HasValue) { + style.LeftIndent = leftIndent.Value; + } + + double? cellSpacing = GetNativeTableCellSpacing(properties?.TableCellSpacing); + if (cellSpacing.HasValue) { + style.CellSpacing = cellSpacing.Value; + } + } + + private static bool IsNativeTableAutoFitToContents(W.TableProperties? properties) => + properties?.TableLayout?.Type?.Value == W.TableLayoutValues.Autofit && + properties.TableWidth?.Type?.Value == W.TableWidthUnitValues.Auto; + + private static double? GetNativeTablePreferredWidth(W.TableWidth? width) { + if (width?.Type?.Value != W.TableWidthUnitValues.Dxa) { + return null; + } + + return ConvertNativeTwipsToPoints(width.Width?.Value); + } + + private static double? GetNativeTableLeftIndent(W.TableIndentation? indentation) { + if (indentation?.Type?.Value != W.TableWidthUnitValues.Dxa || indentation.Width == null) { + return null; + } + + return ConvertNativeTwipsToPoints(indentation.Width.Value); + } + + private static double? GetNativeTableCellSpacing(W.TableCellSpacing? spacing) { + if (spacing?.Type?.Value != W.TableWidthUnitValues.Dxa) { + return null; + } + + return ConvertNativeTwipsToPoints(spacing.Width?.Value); + } + + private static void ApplyNativeTableBorders(WordTable table, PdfCore.PdfTableStyle style) { + (PdfCore.PdfColor Color, double Width)? border = GetNativeUniformTableBorder(table._tableProperties?.TableBorders); + if (border == null) { + return; + } + + style.BorderColor = border.Value.Color; + style.BorderWidth = border.Value.Width; + } + + private static (PdfCore.PdfColor Color, double Width)? GetNativeUniformTableBorder(W.TableBorders? borders) { + if (borders == null) { + return null; + } + + W.BorderType?[] allBorders = { + borders.TopBorder, + borders.BottomBorder, + borders.LeftBorder, + borders.RightBorder, + borders.InsideHorizontalBorder, + borders.InsideVerticalBorder + }; + + if (allBorders.Any(border => border == null || !HasNativeBorder(border.Val?.Value))) { + return null; + } + + W.BorderValues style = allBorders[0]!.Val!.Value; + if (allBorders.Any(border => border!.Val?.Value != style)) { + return null; + } + + uint size = allBorders[0]!.Size?.Value ?? 4U; + if (allBorders.Any(border => (border!.Size?.Value ?? 4U) != size)) { + return null; + } + + string? color = NormalizeNativeBorderColor(allBorders[0]!.Color?.Value); + if (allBorders.Any(border => !string.Equals(color, NormalizeNativeBorderColor(border!.Color?.Value), StringComparison.OrdinalIgnoreCase))) { + return null; + } + + return (ParseNativeColor(color) ?? PdfCore.PdfColor.Black, size / 8D); + } + + private static void ApplyNativeTableDefaultCellMargins(WordTable table, PdfCore.PdfTableStyle style) { + W.TableCellMarginDefault? margins = table._tableProperties?.TableCellMarginDefault; + if (margins == null) { + style.CellPaddingTop = 3D; + style.CellPaddingBottom = 3D; + return; + } + + double? top = ConvertNativeTwipsToPoints(margins.TopMargin?.Width?.Value); + double? bottom = ConvertNativeTwipsToPoints(margins.BottomMargin?.Width?.Value); + double? left = margins.TableCellLeftMargin?.Width == null + ? null + : ConvertNativeTwipsToPoints(margins.TableCellLeftMargin.Width.Value); + double? right = margins.TableCellRightMargin?.Width == null + ? null + : ConvertNativeTwipsToPoints(margins.TableCellRightMargin.Width.Value); + + style.CellPaddingTop = top ?? 3D; + style.CellPaddingBottom = bottom ?? 3D; + + if (left.HasValue) { + style.CellPaddingLeft = left.Value; + } + + if (right.HasValue) { + style.CellPaddingRight = right.Value; + } + } + + private static PdfCore.PdfCellPadding? CreateNativeTableCellPadding(WordTableCell cell) { + double? top = cell.MarginTopWidth.HasValue ? ConvertNativeTwipsToPoints(cell.MarginTopWidth.Value) : null; + double? bottom = cell.MarginBottomWidth.HasValue ? ConvertNativeTwipsToPoints(cell.MarginBottomWidth.Value) : null; + double? left = cell.MarginLeftWidth.HasValue ? ConvertNativeTwipsToPoints(cell.MarginLeftWidth.Value) : null; + double? right = cell.MarginRightWidth.HasValue ? ConvertNativeTwipsToPoints(cell.MarginRightWidth.Value) : null; + if (!top.HasValue && !bottom.HasValue && !left.HasValue && !right.HasValue) { + return null; + } + + return new PdfCore.PdfCellPadding { + Top = top, + Bottom = bottom, + Left = left, + Right = right + }; + } + + private static double? ConvertNativeTwipsToPoints(string? value) { + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int twips) || twips < 0) { + return null; + } + + return twips / 20D; + } + + private static double? ConvertNativeTwipsToPoints(int twips) { + return twips < 0 ? null : twips / 20D; + } + + private static double ConvertNativeEmusToPoints(long emus) { + return emus <= 0 ? 0D : emus / 12700D; + } + + private static void ApplyNativeTableRowOptions(WordTable table, PdfCore.PdfTableStyle style) { + style.AllowRowBreakAcrossPages = table.AllowRowToBreakAcrossPages; + List? rowBreakPolicies = GetNativeTableRowBreakPolicies(table); + if (rowBreakPolicies != null) { + style.RowAllowBreakAcrossPages = rowBreakPolicies; + } + + List? rowHeights = GetNativeTableRowHeights(table); + if (rowHeights == null) { + return; + } + + double? uniformHeight = GetNativeUniformTableRowHeight(rowHeights); + if (uniformHeight.HasValue) { + style.MinRowHeight = uniformHeight.Value; + } else { + style.RowMinHeights = rowHeights; + } + } + + private static List? GetNativeTableRowBreakPolicies(WordTable table) { + var policies = new List(table.Rows.Count); + bool? firstPolicy = null; + bool hasMixedPolicies = false; + foreach (WordTableRow row in table.Rows) { + bool policy = row.AllowRowToBreakAcrossPages; + policies.Add(policy); + if (!firstPolicy.HasValue) { + firstPolicy = policy; + continue; + } + + hasMixedPolicies |= firstPolicy.Value != policy; + } + + return hasMixedPolicies ? policies : null; + } + + private static List? GetNativeTableRowHeights(WordTable table) { + var heights = new List(table.Rows.Count); + bool hasHeight = false; + foreach (WordTableRow row in table.Rows) { + double? height = row.Height.HasValue && row.Height.Value > 0 + ? ConvertNativeTwipsToPoints(row.Height.Value) + : null; + heights.Add(height); + hasHeight |= height.HasValue; + } + + return hasHeight ? heights : null; + } + + private static double? GetNativeUniformTableRowHeight(IReadOnlyList rowHeights) { + double? height = null; + foreach (double? rowHeight in rowHeights) { + if (!rowHeight.HasValue) { + return null; + } + + if (!height.HasValue) { + height = rowHeight.Value; + continue; + } + + if (System.Math.Abs(height.Value - rowHeight.Value) > 0.001D) { + return null; + } + } + + return height; + } + + private static PdfCore.PdfTableStyle? ResolveNativeWordTableStyle(WordTable table) { + WordTableStyle? wordStyle = table.Style; + if (!wordStyle.HasValue) { + return null; + } + + return PdfCore.TableStyles.TryFromWordTableStyle(wordStyle.Value.ToString(), out PdfCore.PdfTableStyle? style) + ? style + : null; + } + + private static int GetNativeTableVisualHeaderRowCount(WordTable table, int rowCount, int repeatedHeaderRowCount) { + if (rowCount == 0) { + return 0; + } + + int headerRowCount = repeatedHeaderRowCount; + if (table.ConditionalFormattingFirstRow == true || headerRowCount > 0) { + headerRowCount = System.Math.Max(headerRowCount, 1); + } + + return System.Math.Min(headerRowCount, rowCount); + } + + private static int GetNativeTableRepeatedHeaderRowCount(WordTable table, int rowCount) { + if (rowCount == 0 || table.Rows.Count == 0) { + return 0; + } + + int repeatedHeaderRowCount = 0; + foreach (WordTableRow row in table.Rows) { + if (!row.RepeatHeaderRowAtTheTopOfEachPage) { + break; + } + + repeatedHeaderRowCount++; + if (repeatedHeaderRowCount == rowCount) { + break; + } + } + + return repeatedHeaderRowCount; + } + + private static PdfCore.PdfAlign MapNativeTableAlignment(W.TableRowAlignmentValues? alignment) { + if (alignment == W.TableRowAlignmentValues.Center) { + return PdfCore.PdfAlign.Center; + } + + if (alignment == W.TableRowAlignmentValues.Right) { + return PdfCore.PdfAlign.Right; + } + + return PdfCore.PdfAlign.Left; + } + + private static List? CreateNativeTableHorizontalAlignments(TableLayout layout) { + int columnCount = GetNativeTableColumnCount(layout); + if (columnCount == 0) { + return null; + } + + var alignments = new List(columnCount); + bool hasExplicitAlignment = false; + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) { + PdfCore.PdfColumnAlign? columnAlignment = null; + bool conflict = false; + foreach ((WordTableCell Cell, int Column, int ColumnSpan) cell in EnumerateNativeTableCells(layout)) { + if (columnIndex < cell.Column || columnIndex >= cell.Column + cell.ColumnSpan) { + continue; + } + + PdfCore.PdfColumnAlign alignment = GetNativeCellHorizontalAlignment(cell.Cell); + if (columnAlignment == null) { + columnAlignment = alignment; + } else if (columnAlignment.Value != alignment) { + conflict = true; + break; + } + } + + PdfCore.PdfColumnAlign resolved = conflict ? PdfCore.PdfColumnAlign.Left : columnAlignment ?? PdfCore.PdfColumnAlign.Left; + if (resolved != PdfCore.PdfColumnAlign.Left) { + hasExplicitAlignment = true; + } + + alignments.Add(resolved); + } + + return hasExplicitAlignment ? alignments : null; + } + + private static PdfCore.PdfColumnAlign GetNativeCellHorizontalAlignment(WordTableCell cell) { + PdfCore.PdfColumnAlign? alignment = null; + foreach (WordParagraph paragraph in cell.Paragraphs) { + string text = GetNativeCellParagraphText(paragraph); + if (string.IsNullOrWhiteSpace(text)) { + continue; + } + + PdfCore.PdfColumnAlign paragraphAlignment = MapNativeColumnAlign(paragraph.ParagraphAlignment); + if (alignment == null) { + alignment = paragraphAlignment; + } else if (alignment.Value != paragraphAlignment) { + return PdfCore.PdfColumnAlign.Left; + } + } + + return alignment ?? PdfCore.PdfColumnAlign.Left; + } + + private static List? CreateNativeTableVerticalAlignments(TableLayout layout) { + int columnCount = GetNativeTableColumnCount(layout); + if (columnCount == 0) { + return null; + } + + var alignments = new List(columnCount); + bool hasExplicitAlignment = false; + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) { + PdfCore.PdfCellVerticalAlign? columnAlignment = null; + bool conflict = false; + foreach ((WordTableCell Cell, int Column, int ColumnSpan) cell in EnumerateNativeTableCells(layout)) { + if (columnIndex < cell.Column || columnIndex >= cell.Column + cell.ColumnSpan) { + continue; + } + + PdfCore.PdfCellVerticalAlign alignment = MapNativeCellVerticalAlign(cell.Cell.VerticalAlignment); + if (columnAlignment == null) { + columnAlignment = alignment; + } else if (columnAlignment.Value != alignment) { + conflict = true; + break; + } + } + + PdfCore.PdfCellVerticalAlign resolved = conflict ? PdfCore.PdfCellVerticalAlign.Top : columnAlignment ?? PdfCore.PdfCellVerticalAlign.Top; + if (resolved != PdfCore.PdfCellVerticalAlign.Top) { + hasExplicitAlignment = true; + } + + alignments.Add(resolved); + } + + return hasExplicitAlignment ? alignments : null; + } + + private static int GetNativeTableColumnCount(TableLayout layout) { + if (layout.ColumnWidths.Length > 0) { + return layout.ColumnWidths.Length; + } + + int columnCount = 0; + foreach (IReadOnlyList row in layout.Rows) { + int logicalColumn = 0; + foreach (WordTableCell cell in row) { + if (IsNativeHorizontalMergeContinuation(cell)) { + continue; + } + + logicalColumn += GetNativeCellColumnSpan(cell); + } + + if (logicalColumn > columnCount) { + columnCount = logicalColumn; + } + } + + return columnCount; + } + + private static IEnumerable<(WordTableCell Cell, int Column, int ColumnSpan)> EnumerateNativeTableCells(TableLayout layout) { + foreach (IReadOnlyList row in layout.Rows) { + int logicalColumn = 0; + foreach (WordTableCell cell in row) { + if (IsNativeHorizontalMergeContinuation(cell)) { + continue; + } + + int columnSpan = GetNativeCellColumnSpan(cell); + if (IsNativeVerticalMergeContinuation(cell)) { + logicalColumn += columnSpan; + continue; + } + + yield return (cell, logicalColumn, columnSpan); + logicalColumn += columnSpan; + } + } + } + + private static bool IsNativeHorizontalMergeContinuation(WordTableCell cell) => + cell.HorizontalMerge == W.MergedCellValues.Continue; + + private static bool IsNativeVerticalMergeContinuation(WordTableCell cell) => + cell.VerticalMerge == W.MergedCellValues.Continue; + + private static int GetNativeCellColumnSpan(WordTableCell cell) => + Math.Max(1, cell.ColumnSpan); + + private static int GetNativeCellRowSpan(WordTableCell cell) => + Math.Max(1, cell.RowSpan); + + private static (string? LinkUri, string? LinkContents) GetNativeCellLink(WordTableCell cell) { + string? linkUri = null; + string? linkContents = null; + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + if (!TryAddNativeCellLink(paragraph, ref linkUri, ref linkContents)) { + return (null, null); + } + + foreach (WordParagraph run in paragraph.GetRuns()) { + if (!TryAddNativeCellLink(run, ref linkUri, ref linkContents)) { + return (null, null); + } + } + } + + return (linkUri, linkContents); + } + + private static bool TryAddNativeCellLink(WordParagraph paragraph, ref string? linkUri, ref string? linkContents) { + if (!paragraph.IsHyperLink || paragraph.Hyperlink == null) { + return true; + } + + Uri? uri = paragraph.Hyperlink.Uri; + if (uri == null || !uri.IsAbsoluteUri) { + return true; + } + + string candidateUri = uri.AbsoluteUri; + if (!string.IsNullOrEmpty(linkUri) && !string.Equals(linkUri, candidateUri, StringComparison.Ordinal)) { + return false; + } + + linkUri = candidateUri; + string? contents = string.IsNullOrWhiteSpace(paragraph.Hyperlink.Tooltip) + ? GetNativeCellParagraphText(paragraph) + : paragraph.Hyperlink.Tooltip; + linkContents ??= string.IsNullOrWhiteSpace(contents) ? null : contents; + return true; + } + + private static PdfCore.PdfCellBorder? CreateNativeTableCellBorder(WordTableCellBorder borders) { + bool top = HasNativeBorder(borders.TopStyle); + bool bottom = HasNativeBorder(borders.BottomStyle); + bool left = HasNativeBorder(borders.LeftStyle); + bool right = HasNativeBorder(borders.RightStyle); + bool diagonalDown = HasNativeBorder(borders.TopLeftToBottomRightStyle); + bool diagonalUp = HasNativeBorder(borders.TopRightToBottomLeftStyle); + if (!top && !bottom && !left && !right && !diagonalDown && !diagonalUp) { + return null; + } + + if (!diagonalDown && !diagonalUp && TryGetNativeUniformTableCellBorder(borders, out PdfCore.PdfColor uniformColor, out double uniformWidth, out OfficeIMO.Drawing.OfficeStrokeDashStyle uniformDashStyle, out PdfCore.PdfCellBorderLineStyle uniformLineStyle)) { + return new PdfCore.PdfCellBorder { + Color = uniformColor, + Width = uniformWidth, + DashStyle = uniformDashStyle, + LineStyle = uniformLineStyle, + Top = top, + Bottom = bottom, + Left = left, + Right = right + }; + } + + return new PdfCore.PdfCellBorder { + Color = null, + Width = 0, + TopBorder = CreateNativeCellBorderSide(borders.TopStyle, borders.TopColorHex, borders.TopSize), + BottomBorder = CreateNativeCellBorderSide(borders.BottomStyle, borders.BottomColorHex, borders.BottomSize), + LeftBorder = CreateNativeCellBorderSide(borders.LeftStyle, borders.LeftColorHex, borders.LeftSize), + RightBorder = CreateNativeCellBorderSide(borders.RightStyle, borders.RightColorHex, borders.RightSize), + DiagonalDownBorder = CreateNativeCellBorderSide(borders.TopLeftToBottomRightStyle, borders.TopLeftToBottomRightColorHex, borders.TopLeftToBottomRightSize), + DiagonalUpBorder = CreateNativeCellBorderSide(borders.TopRightToBottomLeftStyle, borders.TopRightToBottomLeftColorHex, borders.TopRightToBottomLeftSize), + Top = top, + Bottom = bottom, + Left = left, + Right = right, + DiagonalDown = diagonalDown, + DiagonalUp = diagonalUp + }; + } + + private static bool TryGetNativeUniformTableCellBorder(WordTableCellBorder borders, out PdfCore.PdfColor color, out double width, out OfficeIMO.Drawing.OfficeStrokeDashStyle dashStyle, out PdfCore.PdfCellBorderLineStyle lineStyle) { + color = PdfCore.PdfColor.Black; + width = 1D; + dashStyle = OfficeIMO.Drawing.OfficeStrokeDashStyle.Solid; + lineStyle = PdfCore.PdfCellBorderLineStyle.Standard; + + string? firstColor = null; + uint? firstSize = null; + W.BorderValues? firstStyle = null; + bool hasFirst = false; + foreach ((W.BorderValues? BorderStyle, string? Color, DocumentFormat.OpenXml.UInt32Value? Size) side in GetNativeTableCellBorderSides(borders)) { + if (!HasNativeBorder(side.BorderStyle)) { + continue; + } + + string? sideColor = NormalizeNativeBorderColor(side.Color); + uint sideSize = side.Size?.Value ?? 4U; + if (!hasFirst) { + firstColor = sideColor; + firstSize = sideSize; + firstStyle = side.BorderStyle; + hasFirst = true; + continue; + } + + if (!string.Equals(firstColor, sideColor, StringComparison.OrdinalIgnoreCase) || + firstSize.GetValueOrDefault() != sideSize || + firstStyle != side.BorderStyle) { + return false; + } + } + + color = ParseNativeColor(firstColor) ?? PdfCore.PdfColor.Black; + width = (firstSize ?? 4U) / 8D; + dashStyle = ToNativeBorderDashStyle(firstStyle); + lineStyle = ToNativeBorderLineStyle(firstStyle); + return true; + } + + private static IEnumerable<(W.BorderValues? BorderStyle, string? Color, DocumentFormat.OpenXml.UInt32Value? Size)> GetNativeTableCellBorderSides(WordTableCellBorder borders) { + yield return (borders.TopStyle, borders.TopColorHex, borders.TopSize); + yield return (borders.BottomStyle, borders.BottomColorHex, borders.BottomSize); + yield return (borders.LeftStyle, borders.LeftColorHex, borders.LeftSize); + yield return (borders.RightStyle, borders.RightColorHex, borders.RightSize); + } + + private static PdfCore.PdfCellBorderSide? CreateNativeCellBorderSide(W.BorderValues? borderStyle, string? color, DocumentFormat.OpenXml.UInt32Value? size) { + if (!HasNativeBorder(borderStyle)) { + return null; + } + + return new PdfCore.PdfCellBorderSide { + Color = ParseNativeColor(NormalizeNativeBorderColor(color)) ?? PdfCore.PdfColor.Black, + Width = (size?.Value ?? 4U) / 8D, + DashStyle = ToNativeBorderDashStyle(borderStyle), + LineStyle = ToNativeBorderLineStyle(borderStyle) + }; + } + + private static OfficeIMO.Drawing.OfficeStrokeDashStyle ToNativeBorderDashStyle(W.BorderValues? borderStyle) { + string value = borderStyle?.ToString() ?? string.Empty; + if (value.IndexOf("dot", StringComparison.OrdinalIgnoreCase) >= 0 && + value.IndexOf("dash", StringComparison.OrdinalIgnoreCase) >= 0) { + return OfficeIMO.Drawing.OfficeStrokeDashStyle.DashDot; + } + + if (value.IndexOf("dash", StringComparison.OrdinalIgnoreCase) >= 0) { + return OfficeIMO.Drawing.OfficeStrokeDashStyle.Dash; + } + + if (value.IndexOf("dot", StringComparison.OrdinalIgnoreCase) >= 0) { + return OfficeIMO.Drawing.OfficeStrokeDashStyle.Dot; + } + + return OfficeIMO.Drawing.OfficeStrokeDashStyle.Solid; + } + + private static PdfCore.PdfCellBorderLineStyle ToNativeBorderLineStyle(W.BorderValues? borderStyle) => + borderStyle == W.BorderValues.Double + ? PdfCore.PdfCellBorderLineStyle.TwoLine + : PdfCore.PdfCellBorderLineStyle.Standard; + + private static string GetNativeCellText(WordTableCell cell) => + GetNativeCellText(cell, null); + + private static IReadOnlyList CreateNativeCellRuns(WordTableCell cell, Dictionary? footnoteNumbersById) { + var runs = new List(); + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + List paragraphRuns = CreateNativeCellParagraphRuns(paragraph, footnoteNumbersById); + if (paragraphRuns.Count == 0) { + continue; + } + + if (runs.Count > 0) { + runs.Add(PdfCore.TextRun.LineBreak()); + } + + runs.AddRange(paragraphRuns); + } + + return runs; + } + + private static List CreateNativeCellParagraphRuns(WordParagraph paragraph, Dictionary? footnoteNumbersById) { + var result = new List(); + List runs = GetNativeRuns(paragraph); + string content = paragraph.IsHyperLink && paragraph.Hyperlink != null ? paragraph.Hyperlink.Text : AppendNativeTextWithEquation(paragraph.Text, paragraph); + bool hasRenderableRuns = runs.Any(run => !run.IsImage && !string.IsNullOrEmpty(run.Text)); + IReadOnlyList tabStops = paragraph.TabStops; + int tabIndex = 0; + IReadOnlyList repeatingSectionControls = GetNativeRepeatingSectionControls(paragraph); + + if (hasRenderableRuns) { + foreach (WordParagraph run in runs) { + if (run.IsImage && run.Image != null) { + continue; + } + + if (IsNativeTextWrappingBreak(run)) { + result.Add(PdfCore.TextRun.LineBreak()); + tabIndex = 0; + continue; + } + + AddNativeCellRun(result, run, tabStops, ref tabIndex); + } + + string? supplementalText = GetNativeSupplementalTextAfterRuns(content, runs); + if (!string.IsNullOrEmpty(supplementalText)) { + AddNativeCellText(result, supplementalText!, paragraph, tabStops, ref tabIndex); + } + } else if (paragraph.IsHyperLink && paragraph.Hyperlink != null && !string.IsNullOrEmpty(paragraph.Hyperlink.Text)) { + AddNativeCellHyperLinkRun(result, paragraph.Hyperlink.Text, paragraph, paragraph.Hyperlink, tabStops, ref tabIndex); + } else if (!string.IsNullOrEmpty(content)) { + AddNativeCellText(result, content, paragraph, tabStops, ref tabIndex); + } + + foreach (W.SdtRun repeatingSection in repeatingSectionControls) { + foreach (string itemText in GetNativeRepeatingSectionItems(repeatingSection)) { + if (string.IsNullOrWhiteSpace(itemText)) { + continue; + } + + if (result.Count > 0) { + result.Add(PdfCore.TextRun.LineBreak()); + tabIndex = 0; + } + + AddNativeCellText(result, itemText, paragraph, tabStops, ref tabIndex); + } + } + + if (footnoteNumbersById != null) { + List paragraphFootnoteNumbers = GetNativeParagraphFootnoteNumbers(paragraph, runs, Array.Empty(), footnoteNumbersById); + AddNativeCellFootnoteReferences(result, paragraphFootnoteNumbers); + } + + return result; + } + + private static void AddNativeCellRun(List target, WordParagraph run, IReadOnlyList tabStops, ref int tabIndex) { + if (string.IsNullOrEmpty(run.Text)) { + return; + } + + if (run.IsHyperLink && run.Hyperlink != null) { + AddNativeCellHyperLinkRun(target, run.Text, run, run.Hyperlink, tabStops, ref tabIndex); + return; + } + + AddNativeCellTextRuns(target, run.Text, text => CreateNativeCellTextRun(text, run), tabStops, ref tabIndex); + } + + private static void AddNativeCellText(List target, string text, WordParagraph paragraph, IReadOnlyList tabStops, ref int tabIndex) { + AddNativeCellTextRuns(target, text, value => CreateNativeCellTextRun(value, paragraph), tabStops, ref tabIndex); + } + + private static void AddNativeCellHyperLinkRun(List target, string text, WordParagraph paragraph, WordHyperLink hyperlink, IReadOnlyList tabStops, ref int tabIndex) { + AddNativeCellTextRuns(target, text, value => CreateNativeCellLinkRun(value, paragraph, hyperlink), tabStops, ref tabIndex); + } + + private static void AddNativeCellTextRuns(List target, string text, Func createRun, IReadOnlyList tabStops, ref int tabIndex) { + int currentTabIndex = tabIndex; + AddNativeTextSegments( + text, + value => target.Add(createRun(value)), + () => target.Add(PdfCore.TextRun.LineBreak()), + () => { + target.Add(CreateNativeCellTabRun(tabStops, currentTabIndex)); + currentTabIndex++; + }, + () => currentTabIndex = 0); + tabIndex = currentTabIndex; + } + + private static PdfCore.TextRun CreateNativeCellTextRun(string text, WordParagraph paragraph) => + new PdfCore.TextRun( + text, + bold: paragraph.Bold, + underline: paragraph.Underline != null, + color: ParseNativeColor(paragraph.ColorHex), + italic: paragraph.Italic, + strike: paragraph.Strike || paragraph.DoubleStrike, + fontSize: paragraph.FontSize.HasValue && paragraph.FontSize.Value > 0 ? paragraph.FontSize.Value : null, + baseline: GetNativeTextBaseline(paragraph), + backgroundColor: MapNativeHighlight(paragraph.Highlight)); + + private static PdfCore.TextRun CreateNativeCellLinkRun(string text, WordParagraph paragraph, WordHyperLink hyperlink) { + Uri? uri = hyperlink.Uri; + string? linkUri = uri != null && uri.IsAbsoluteUri ? uri.AbsoluteUri : null; + string? destinationName = string.IsNullOrWhiteSpace(hyperlink.Anchor) ? null : hyperlink.Anchor; + if (linkUri == null && destinationName == null) { + return CreateNativeCellTextRun(text, paragraph); + } + + string? contents = string.IsNullOrWhiteSpace(hyperlink.Tooltip) ? null : hyperlink.Tooltip; + return new PdfCore.TextRun( + text, + bold: paragraph.Bold, + underline: paragraph.Underline != null || linkUri != null || destinationName != null, + color: ParseNativeColor(paragraph.ColorHex), + italic: paragraph.Italic, + strike: paragraph.Strike || paragraph.DoubleStrike, + fontSize: paragraph.FontSize.HasValue && paragraph.FontSize.Value > 0 ? paragraph.FontSize.Value : null, + linkUri: linkUri, + linkContents: contents, + baseline: GetNativeTextBaseline(paragraph), + linkDestinationName: destinationName, + backgroundColor: MapNativeHighlight(paragraph.Highlight)); + } + + private static PdfCore.TextRun CreateNativeCellTabRun(IReadOnlyList tabStops, int tabIndex) { + if (tabIndex < tabStops.Count) { + WordTabStop tabStop = tabStops[tabIndex]; + return PdfCore.TextRun.Tab(MapNativeTabLeader(tabStop.Leader), MapNativeTabAlignment(tabStop.Alignment)); + } + + return PdfCore.TextRun.Tab(); + } + + private static PdfCore.PdfTextBaseline GetNativeTextBaseline(WordParagraph paragraph) => + paragraph.VerticalTextAlignment == W.VerticalPositionValues.Superscript + ? PdfCore.PdfTextBaseline.Superscript + : paragraph.VerticalTextAlignment == W.VerticalPositionValues.Subscript + ? PdfCore.PdfTextBaseline.Subscript + : PdfCore.PdfTextBaseline.Normal; + + private static void AddNativeCellFootnoteReferences(List target, IReadOnlyList footnoteNumbers) { + foreach (int footnoteNumber in footnoteNumbers) { + target.Add(PdfCore.TextRun.Superscript(footnoteNumber.ToString(CultureInfo.InvariantCulture))); + } + } + + private static string GetNativeCellText(WordTableCell cell, Dictionary? footnoteNumbersById) { + var parts = new List(); + foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + string? paragraphText = GetNativeCellParagraphText(paragraph); + if (!string.IsNullOrEmpty(paragraphText)) { + string text = paragraphText; + if (footnoteNumbersById != null) { + List paragraphFootnoteNumbers = GetNativeParagraphFootnoteNumbers(paragraph, GetNativeRuns(paragraph), Array.Empty(), footnoteNumbersById); + if (paragraphFootnoteNumbers.Count > 0) { + text += string.Concat(paragraphFootnoteNumbers.Select(number => number.ToString(CultureInfo.InvariantCulture))); + } + } + + parts.Add(text); + } + } + + return string.Join(Environment.NewLine, parts); + } + + private static IReadOnlyList GetNativeCellParagraphs(WordTableCell cell) => + CollapseNativeParagraphElements(cell.Paragraphs.Cast()) + .OfType() + .ToList(); + + private static string GetNativeCellParagraphText(WordParagraph paragraph) { + if (paragraph.IsHyperLink && paragraph.Hyperlink != null && !string.IsNullOrEmpty(paragraph.Hyperlink.Text)) { + return paragraph.Hyperlink.Text; + } + + if (!string.IsNullOrEmpty(paragraph.Text)) { + return AppendNativeTextWithEquation(paragraph.Text, paragraph); + } + + var parts = new List(); + foreach (WordParagraph run in paragraph.GetRuns()) { + string runText = run.IsHyperLink && run.Hyperlink != null ? run.Hyperlink.Text : run.Text; + if (!string.IsNullOrEmpty(runText)) { + parts.Add(runText); + } + } + + string text = string.Concat(parts); + return AppendNativeTextWithEquation(text, paragraph); + } + + private static List CollectNativeFootnotes(IReadOnlyList elements, out Dictionary footnoteNumbersById) { + var footnotes = new List(); + footnoteNumbersById = new Dictionary(); + foreach (WordElement element in elements) { + CollectNativeFootnotes(element, footnotes, footnoteNumbersById); + } + + return footnotes; + } + + private static void CollectNativeFootnotes(WordElement element, List footnotes, Dictionary footnoteNumbersById) { + switch (element) { + case WordFootNote footNote: + AddNativeFootnote(footNote, footnotes, footnoteNumbersById); + break; + case WordEndNote endNote: + AddNativeEndnote(endNote, footnotes, footnoteNumbersById); + break; + case WordParagraph paragraph: + WordFootNote? paragraphFootnote = paragraph.FootNote; + if (paragraphFootnote != null) { + AddNativeFootnote(paragraphFootnote, footnotes, footnoteNumbersById); + } + + WordEndNote? paragraphEndnote = paragraph.EndNote; + if (paragraphEndnote != null) { + AddNativeEndnote(paragraphEndnote, footnotes, footnoteNumbersById); + } + + foreach (WordParagraph run in paragraph.GetRuns()) { + WordFootNote? runFootnote = run.FootNote; + if (runFootnote != null) { + AddNativeFootnote(runFootnote, footnotes, footnoteNumbersById); + } + + WordEndNote? runEndnote = run.EndNote; + if (runEndnote != null) { + AddNativeEndnote(runEndnote, footnotes, footnoteNumbersById); + } + } + + break; + case WordTable table: + foreach (WordTableRow row in table.Rows) { + foreach (WordTableCell cell in row.Cells) { + foreach (WordParagraph paragraph in cell.Paragraphs) { + CollectNativeFootnotes(paragraph, footnotes, footnoteNumbersById); + } + + foreach (WordTable nested in cell.NestedTables) { + CollectNativeFootnotes(nested, footnotes, footnoteNumbersById); + } + } + } + + break; + } + } + + private static void AddNativeFootnote(WordFootNote footNote, List footnotes, Dictionary footnoteNumbersById) { + long? referenceId = footNote.ReferenceId; + if (!referenceId.HasValue || referenceId.Value == 0) { + return; + } + + long key = GetNativeFootnoteKey(referenceId.Value); + if (footnoteNumbersById.ContainsKey(key)) { + return; + } + + int number = footnotes.Count + 1; + footnoteNumbersById[key] = number; + footnotes.Add(new PdfFootnote { + Number = number, + Text = GetNativeFootnoteText(footNote) + }); + } + + private static void AddNativeEndnote(WordEndNote endNote, List footnotes, Dictionary footnoteNumbersById) { + long? referenceId = endNote.ReferenceId; + if (!referenceId.HasValue || referenceId.Value == 0) { + return; + } + + long key = GetNativeEndnoteKey(referenceId.Value); + if (footnoteNumbersById.ContainsKey(key)) { + return; + } + + int number = footnotes.Count + 1; + footnoteNumbersById[key] = number; + footnotes.Add(new PdfFootnote { + Number = number, + Text = GetNativeEndnoteText(endNote) + }); + } + + private static string GetNativeFootnoteText(WordFootNote footNote) { + var parts = new List(); + foreach (WordParagraph paragraph in footNote.Paragraphs ?? Enumerable.Empty()) { + if (!string.IsNullOrWhiteSpace(paragraph.Text)) { + parts.Add(paragraph.Text); + } + } + + return string.Join(" ", parts); + } + + private static string GetNativeEndnoteText(WordEndNote endNote) { + var parts = new List(); + foreach (WordParagraph paragraph in endNote.Paragraphs ?? Enumerable.Empty()) { + if (!string.IsNullOrWhiteSpace(paragraph.Text)) { + parts.Add(paragraph.Text); + } + } + + return string.Join(" ", parts); + } + + private static IReadOnlyList GetNativeFootnoteNumbersForElement(IReadOnlyList elements, int index, Dictionary footnoteNumbersById) { + var numbers = new List(); + for (int i = index + 1; i < elements.Count && (elements[i] is WordFootNote || elements[i] is WordEndNote); i++) { + long? key = GetNativeNoteKey(elements[i]); + if (key.HasValue && footnoteNumbersById.TryGetValue(key.Value, out int number)) { + numbers.Add(number); + } + } + + return numbers; + } + + private static List GetNativeParagraphFootnoteNumbers(WordParagraph paragraph, IReadOnlyList runs, IReadOnlyList followingFootnoteNumbers, Dictionary footnoteNumbersById) { + var numbers = new List(followingFootnoteNumbers); + AddNativeParagraphFootnoteNumber(paragraph, numbers, footnoteNumbersById); + foreach (WordParagraph run in runs) { + AddNativeParagraphFootnoteNumber(run, numbers, footnoteNumbersById); + } + + return numbers.Distinct().ToList(); + } + + private static void AddNativeParagraphFootnoteNumber(WordParagraph paragraph, List numbers, Dictionary footnoteNumbersById) { + WordFootNote? footNote = paragraph.FootNote; + long? footnoteKey = footNote?.ReferenceId.HasValue == true && footNote.ReferenceId.Value != 0 ? GetNativeFootnoteKey(footNote.ReferenceId.Value) : null; + if (footnoteKey.HasValue && footnoteNumbersById.TryGetValue(footnoteKey.Value, out int number)) { + numbers.Add(number); + } + + WordEndNote? endNote = paragraph.EndNote; + long? endnoteKey = endNote?.ReferenceId.HasValue == true && endNote.ReferenceId.Value != 0 ? GetNativeEndnoteKey(endNote.ReferenceId.Value) : null; + if (endnoteKey.HasValue && footnoteNumbersById.TryGetValue(endnoteKey.Value, out number)) { + numbers.Add(number); + } + } + + private static long? GetNativeNoteKey(WordElement element) { + switch (element) { + case WordFootNote footNote when footNote.ReferenceId.HasValue && footNote.ReferenceId.Value != 0: + return GetNativeFootnoteKey(footNote.ReferenceId.Value); + case WordEndNote endNote when endNote.ReferenceId.HasValue && endNote.ReferenceId.Value != 0: + return GetNativeEndnoteKey(endNote.ReferenceId.Value); + default: + return null; + } + } + + private static long GetNativeFootnoteKey(long referenceId) => referenceId; + + private static long GetNativeEndnoteKey(long referenceId) => -referenceId; + + private static void RenderNativeFootnotes(INativePdfFlow pdf, IReadOnlyList footnotes) { + if (footnotes.Count == 0) { + return; + } + + pdf.HR(thickness: 0.5, color: PdfCore.PdfColor.LightGray, spacingBefore: 8, spacingAfter: 4); + foreach (PdfFootnote footnote in footnotes) { + pdf.Paragraph(builder => { + builder.Baseline(PdfCore.PdfTextBaseline.Superscript); + builder.Text(footnote.Number.ToString(CultureInfo.InvariantCulture)); + builder.Baseline(PdfCore.PdfTextBaseline.Normal); + if (!string.IsNullOrWhiteSpace(footnote.Text)) { + builder.Text(" "); + builder.Text(footnote.Text); + } + }); + } + } + + private static void RenderNativeImage(INativePdfFlow pdf, WordImage image, PdfCore.PdfAlign align = PdfCore.PdfAlign.Left) { + if (image == null) { + return; + } + + byte[] bytes = ImageEmbedder.GetImageBytes(image); + double width = image.Width.HasValue ? image.Width.Value * 72D / 96D : 144D; + double height = image.Height.HasValue ? image.Height.Value * 72D / 96D : 144D; + pdf.Image(bytes, width, height, align); + } + + private static PdfCore.PdfParagraphStyle CreateNativeParagraphStyle(WordParagraph paragraph) { + var style = new PdfCore.PdfParagraphStyle(); + if (paragraph.LineSpacingBeforePoints.HasValue) { + style.SpacingBefore = paragraph.LineSpacingBeforePoints.Value; + } + + if (paragraph.LineSpacingAfterPoints.HasValue) { + style.SpacingAfter = paragraph.LineSpacingAfterPoints.Value; + } + + if (paragraph.IndentationBeforePoints.HasValue) { + style.LeftIndent = paragraph.IndentationBeforePoints.Value; + } + + if (paragraph.IndentationAfterPoints.HasValue) { + style.RightIndent = paragraph.IndentationAfterPoints.Value; + } + + if (paragraph.IndentationFirstLinePoints.HasValue) { + style.FirstLineIndent = paragraph.IndentationFirstLinePoints.Value; + } + + double fontSize = paragraph.FontSize.HasValue && paragraph.FontSize.Value > 0 ? paragraph.FontSize.Value : 11D; + style.LineHeight = ResolveNativeParagraphLineHeight(paragraph, fontSize); + + if (!paragraph.LineSpacingAfterPoints.HasValue) { + style.SpacingAfter = NativeDefaultParagraphSpacingAfter; + } + + double? defaultTabStopWidth = GetNativeDefaultTabStopWidth(paragraph); + if (defaultTabStopWidth.HasValue) { + style.DefaultTabStopWidth = defaultTabStopWidth.Value; + } + + style.KeepTogether = paragraph.KeepLinesTogether; + style.KeepWithNext = paragraph.KeepWithNext; + style.WidowControl = paragraph.AvoidWidowAndOrphan; + return style; + } + + private static double ResolveNativeParagraphLineHeight(WordParagraph paragraph, double fontSize) { + if (paragraph.LineSpacing.HasValue && paragraph.LineSpacingRule == W.LineSpacingRuleValues.Auto) { + return Math.Max(0.01D, paragraph.LineSpacing.Value / 240D); + } + + if (paragraph.LineSpacingPoints.HasValue && fontSize > 0D) { + return paragraph.LineSpacingPoints.Value / fontSize; + } + + return NativeDefaultParagraphLineHeight; + } + + private static double? GetNativeDefaultTabStopWidth(WordParagraph paragraph) { + int firstTabStop = paragraph.TabStops + .Where(tabStop => tabStop.Position > 0) + .Select(tabStop => tabStop.Position) + .DefaultIfEmpty(0) + .Min(); + + return firstTabStop > 0 ? ConvertNativeTwipsToPoints(firstTabStop) : null; + } + + private static PdfCore.PdfTabLeaderStyle MapNativeTabLeader(W.TabStopLeaderCharValues leader) { + if (leader == W.TabStopLeaderCharValues.Dot || leader == W.TabStopLeaderCharValues.MiddleDot || leader == W.TabStopLeaderCharValues.Heavy) { + return PdfCore.PdfTabLeaderStyle.Dots; + } + + if (leader == W.TabStopLeaderCharValues.Hyphen) { + return PdfCore.PdfTabLeaderStyle.Hyphens; + } + + if (leader == W.TabStopLeaderCharValues.Underscore) { + return PdfCore.PdfTabLeaderStyle.Underscores; + } + + return PdfCore.PdfTabLeaderStyle.None; + } + + private static PdfCore.PdfTabAlignment MapNativeTabAlignment(W.TabStopValues alignment) { + if (alignment == W.TabStopValues.Center) { + return PdfCore.PdfTabAlignment.Center; + } + + if (alignment == W.TabStopValues.Right) { + return PdfCore.PdfTabAlignment.Right; + } + + if (alignment == W.TabStopValues.Decimal) { + return PdfCore.PdfTabAlignment.DecimalSeparator; + } + + return PdfCore.PdfTabAlignment.Left; + } + + private static PdfCore.PanelStyle? CreateNativeParagraphPanelStyle(WordParagraph paragraph, PdfCore.PdfParagraphStyle paragraphStyle) { + PdfCore.PdfColor? background = ParseNativeColor(paragraph.ShadingFillColorHex); + (PdfCore.PdfColor? Color, double Width)? border = GetNativeUniformParagraphBorder(paragraph.Borders); + bool renderAsRule = !background.HasValue && + (HasNativeOnlyTopParagraphBorder(paragraph.Borders) || HasNativeOnlyBottomParagraphBorder(paragraph.Borders)); + if (renderAsRule) { + return null; + } + + bool hasParagraphBorder = HasNativeParagraphBorder(paragraph.Borders); + if (!background.HasValue && border == null && !hasParagraphBorder) { + return null; + } + + var style = new PdfCore.PanelStyle { + Background = background, + BorderColor = border?.Color, + BorderWidth = border?.Width ?? 0D, + PaddingX = 6, + PaddingY = 4, + SpacingBefore = paragraphStyle.SpacingBefore, + SpacingAfter = paragraphStyle.SpacingAfter ?? 6D, + Align = MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false) + }; + + if (border == null && hasParagraphBorder) { + style.TopBorder = CreateNativePanelBorder(paragraph.Borders.TopStyle, paragraph.Borders.TopColorHex, paragraph.Borders.TopSize); + style.RightBorder = CreateNativePanelBorder(paragraph.Borders.RightStyle, paragraph.Borders.RightColorHex, paragraph.Borders.RightSize); + style.BottomBorder = CreateNativePanelBorder(paragraph.Borders.BottomStyle, paragraph.Borders.BottomColorHex, paragraph.Borders.BottomSize); + style.LeftBorder = CreateNativePanelBorder(paragraph.Borders.LeftStyle, paragraph.Borders.LeftColorHex, paragraph.Borders.LeftSize); + } + + return style; + } + + private static bool IsNativeHorizontalRuleParagraph(WordParagraph paragraph, IReadOnlyList runs, string content) { + if (!string.IsNullOrEmpty(content) || + paragraph.Image != null || + paragraph.Shape != null || + paragraph.TextBox != null || + runs.Any(run => run.IsImage || !string.IsNullOrEmpty(run.Text))) { + return false; + } + + return HasNativeOnlyBottomParagraphBorder(paragraph.Borders); + } + + private static PdfCore.PdfHorizontalRuleStyle? CreateNativeHorizontalRuleStyle(WordParagraph paragraph, PdfCore.PdfParagraphStyle paragraphStyle) { + WordParagraphBorders borders = paragraph.Borders; + if (!HasNativeBorder(borders.BottomStyle)) { + return null; + } + + return new PdfCore.PdfHorizontalRuleStyle { + Thickness = (borders.BottomSize?.Value ?? 4U) / 8D, + Color = ParseNativeColor(NormalizeNativeBorderColor(borders.BottomColorHex)) ?? PdfCore.PdfColor.Black, + SpacingBefore = paragraphStyle.SpacingBefore, + SpacingAfter = paragraphStyle.SpacingAfter ?? (borders.BottomSpace?.Value ?? 6U), + KeepWithNext = paragraphStyle.KeepWithNext + }; + } + + private static PdfCore.PdfHorizontalRuleStyle? CreateNativeBottomBorderRuleStyle(WordParagraph paragraph, PdfCore.PdfParagraphStyle paragraphStyle) { + WordParagraphBorders borders = paragraph.Borders; + if (!HasNativeOnlyBottomParagraphBorder(borders)) { + return null; + } + + return new PdfCore.PdfHorizontalRuleStyle { + Thickness = (borders.BottomSize?.Value ?? 4U) / 8D, + Color = ParseNativeColor(NormalizeNativeBorderColor(borders.BottomColorHex)) ?? PdfCore.PdfColor.Black, + SpacingBefore = borders.BottomSpace?.Value ?? 0D, + SpacingAfter = paragraphStyle.SpacingAfter ?? 6D, + KeepWithNext = paragraphStyle.KeepWithNext + }; + } + + private static PdfCore.PdfHorizontalRuleStyle? CreateNativeTopBorderRuleStyle(WordParagraph paragraph, PdfCore.PdfParagraphStyle paragraphStyle) { + WordParagraphBorders borders = paragraph.Borders; + if (!HasNativeOnlyTopParagraphBorder(borders)) { + return null; + } + + return new PdfCore.PdfHorizontalRuleStyle { + Thickness = (borders.TopSize?.Value ?? 4U) / 8D, + Color = ParseNativeColor(NormalizeNativeBorderColor(borders.TopColorHex)) ?? PdfCore.PdfColor.Black, + SpacingBefore = paragraphStyle.SpacingBefore, + SpacingAfter = borders.TopSpace?.Value ?? 0D, + KeepWithNext = true + }; + } + + private static (PdfCore.PdfColor? Color, double Width)? GetNativeUniformParagraphBorder(WordParagraphBorders borders) { + if (!HasNativeBorder(borders.TopStyle) || + !HasNativeBorder(borders.BottomStyle) || + !HasNativeBorder(borders.LeftStyle) || + !HasNativeBorder(borders.RightStyle)) { + return null; + } + + if (borders.TopStyle != borders.BottomStyle || + borders.TopStyle != borders.LeftStyle || + borders.TopStyle != borders.RightStyle) { + return null; + } + + uint topSize = borders.TopSize?.Value ?? 4U; + if (topSize != (borders.BottomSize?.Value ?? 4U) || + topSize != (borders.LeftSize?.Value ?? 4U) || + topSize != (borders.RightSize?.Value ?? 4U)) { + return null; + } + + string? topColor = NormalizeNativeBorderColor(borders.TopColorHex); + if (!string.Equals(topColor, NormalizeNativeBorderColor(borders.BottomColorHex), StringComparison.OrdinalIgnoreCase) || + !string.Equals(topColor, NormalizeNativeBorderColor(borders.LeftColorHex), StringComparison.OrdinalIgnoreCase) || + !string.Equals(topColor, NormalizeNativeBorderColor(borders.RightColorHex), StringComparison.OrdinalIgnoreCase)) { + return null; + } + + PdfCore.PdfColor color = ParseNativeColor(topColor) ?? PdfCore.PdfColor.Black; + return (color, topSize / 8D); + } + + private static bool HasNativeBorder(W.BorderValues? style) => + style != null && style != W.BorderValues.Nil && style != W.BorderValues.None; + + private static bool HasNativeParagraphBorder(WordParagraphBorders borders) => + HasNativeBorder(borders.TopStyle) || + HasNativeBorder(borders.RightStyle) || + HasNativeBorder(borders.BottomStyle) || + HasNativeBorder(borders.LeftStyle); + + private static PdfCore.PdfPanelBorder? CreateNativePanelBorder(W.BorderValues? borderStyle, string? color, DocumentFormat.OpenXml.UInt32Value? size) { + if (!HasNativeBorder(borderStyle)) { + return null; + } + + return new PdfCore.PdfPanelBorder { + Color = ParseNativeColor(NormalizeNativeBorderColor(color)) ?? PdfCore.PdfColor.Black, + Width = (size?.Value ?? 4U) / 8D + }; + } + + private static bool HasNativeOnlyBottomParagraphBorder(WordParagraphBorders borders) => + HasNativeBorder(borders.BottomStyle) && + !HasNativeBorder(borders.TopStyle) && + !HasNativeBorder(borders.LeftStyle) && + !HasNativeBorder(borders.RightStyle); + + private static bool HasNativeOnlyTopParagraphBorder(WordParagraphBorders borders) => + HasNativeBorder(borders.TopStyle) && + !HasNativeBorder(borders.BottomStyle) && + !HasNativeBorder(borders.LeftStyle) && + !HasNativeBorder(borders.RightStyle); + + private static string? NormalizeNativeBorderColor(string? color) => + string.IsNullOrWhiteSpace(color) || string.Equals(color, "auto", StringComparison.OrdinalIgnoreCase) + ? null + : color; + + private static PdfCore.PdfAlign MapNativeParagraphAlign(W.JustificationValues? alignment, bool allowJustify = true) { + if (alignment == W.JustificationValues.Center) { + return PdfCore.PdfAlign.Center; + } + + if (alignment == W.JustificationValues.Right) { + return PdfCore.PdfAlign.Right; + } + + if (allowJustify && + (alignment == W.JustificationValues.Both || + alignment == W.JustificationValues.Distribute || + alignment == W.JustificationValues.HighKashida || + alignment == W.JustificationValues.LowKashida || + alignment == W.JustificationValues.MediumKashida || + alignment == W.JustificationValues.ThaiDistribute)) { + return PdfCore.PdfAlign.Justify; + } + + return PdfCore.PdfAlign.Left; + } + + private static PdfCore.PdfColumnAlign MapNativeColumnAlign(W.JustificationValues? alignment) { + if (alignment == W.JustificationValues.Center) { + return PdfCore.PdfColumnAlign.Center; + } + + if (alignment == W.JustificationValues.Right) { + return PdfCore.PdfColumnAlign.Right; + } + + return PdfCore.PdfColumnAlign.Left; + } + + private static PdfCore.PdfCellVerticalAlign MapNativeCellVerticalAlign(W.TableVerticalAlignmentValues? alignment) { + if (alignment == W.TableVerticalAlignmentValues.Center) { + return PdfCore.PdfCellVerticalAlign.Middle; + } + + if (alignment == W.TableVerticalAlignmentValues.Bottom) { + return PdfCore.PdfCellVerticalAlign.Bottom; + } + + return PdfCore.PdfCellVerticalAlign.Top; + } + + private static int GetHeadingLevel(WordParagraph paragraph) { + if (!paragraph.Style.HasValue) { + return 0; + } + + return paragraph.Style.Value switch { + WordParagraphStyles.Heading1 => 1, + WordParagraphStyles.Heading2 => 2, + WordParagraphStyles.Heading3 => 3, + WordParagraphStyles.Heading4 => 3, + WordParagraphStyles.Heading5 => 3, + WordParagraphStyles.Heading6 => 3, + _ => 0 + }; + } + + private static PdfCore.PdfColor? GetNativeHeadingColor(int headingLevel, PdfCore.PdfColor? explicitColor) { + if (explicitColor.HasValue || headingLevel <= 0) { + return explicitColor; + } + + return PdfCore.PdfColor.FromRgb(47, 84, 150); + } + + private static PdfCore.PageSize GetNativePageSize(WordSection section, PdfSaveOptions? options) { + PdfCore.PageSize size; + if (options?.OfficeIMOPageSize != null) { + size = options.OfficeIMOPageSize.Value; + if (options.Orientation == null) { + return size; + } + } else if (section.PageSettings.Width?.Value > 0 && section.PageSettings.Height?.Value > 0) { + size = new PdfCore.PageSize(section.PageSettings.Width.Value / 20D, section.PageSettings.Height.Value / 20D); + } else if (section.PageSettings.PageSize.HasValue) { + size = MapNativePageSize(section.PageSettings.PageSize.Value); + } else if (options?.DefaultPageSize.HasValue == true) { + size = MapNativePageSize(options.DefaultPageSize.Value); + } else { + size = PdfCore.PageSizes.A4; + } + + PdfPageOrientation orientation; + if (options?.Orientation != null) { + orientation = options.Orientation.Value; + } else if (section.PageSettings.Orientation == W.PageOrientationValues.Landscape) { + orientation = PdfPageOrientation.Landscape; + } else if (options?.DefaultOrientation != null) { + orientation = options.DefaultOrientation == W.PageOrientationValues.Landscape ? PdfPageOrientation.Landscape : PdfPageOrientation.Portrait; + } else { + orientation = PdfPageOrientation.Portrait; + } + + return orientation == PdfPageOrientation.Landscape ? size.Landscape() : size.Portrait(); + } + + private static PdfCore.PageSize MapNativePageSize(WordPageSize pageSize) => + pageSize switch { + WordPageSize.Letter => PdfCore.PageSizes.Letter, + WordPageSize.Legal => PdfCore.PageSizes.Legal, + WordPageSize.A3 => new PdfCore.PageSize(842, 1191), + WordPageSize.A4 => PdfCore.PageSizes.A4, + WordPageSize.A5 => PdfCore.PageSizes.A5, + WordPageSize.A6 => new PdfCore.PageSize(298, 420), + WordPageSize.B5 => new PdfCore.PageSize(499, 709), + WordPageSize.Executive => new PdfCore.PageSize(522, 756), + WordPageSize.Statement => new PdfCore.PageSize(396, 612), + _ => PdfCore.PageSizes.A4 + }; + + private static PdfCore.PageMargins GetNativeMargins(WordSection section, PdfSaveOptions? options) { + if (options?.OfficeIMOMargins != null) { + return options.OfficeIMOMargins.Value; + } + + return new PdfCore.PageMargins( + (section.Margins.Left?.Value ?? 0) / 20D, + (section.Margins.Top ?? 0) / 20D, + (section.Margins.Right?.Value ?? 0) / 20D, + (section.Margins.Bottom ?? 0) / 20D); + } + + private static string GetNativePageNumberFormat(PdfSaveOptions? options) { + string? format = options?.PageNumberFormat; + if (string.IsNullOrWhiteSpace(format)) { + return "{page}/{pages}"; + } + + return format!.Replace("{current}", "{page}").Replace("{total}", "{pages}"); + } + + private static string? BuildNativeKeywords(PdfSaveOptions? options, BuiltinDocumentProperties properties) { + string? keys = options?.Keywords ?? properties.Keywords; + string? family = options?.FontFamily; + if (!string.IsNullOrWhiteSpace(family)) { + keys = string.IsNullOrWhiteSpace(keys) ? family : keys + ";" + family; + } + + return keys; + } + + private static PdfCore.PdfColor? ParseNativeColor(string? hex) { + if (hex == null || string.IsNullOrWhiteSpace(hex) || hex.Equals("auto", StringComparison.OrdinalIgnoreCase)) { + return null; + } + + string value = hex.Trim(); + if (value.StartsWith("#", StringComparison.Ordinal)) { + value = value.Substring(1); + } + + if (value.Length != 6 || + !byte.TryParse(value.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte r) || + !byte.TryParse(value.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte g) || + !byte.TryParse(value.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte b)) { + return null; + } + + return PdfCore.PdfColor.FromRgb(r, g, b); + } + + private static PdfCore.PdfColor? MapNativeHighlight(W.HighlightColorValues? highlight) { + if (!highlight.HasValue || highlight.Value == W.HighlightColorValues.None) { + return null; + } + + if (highlight.Value == W.HighlightColorValues.Black) return PdfCore.PdfColor.Black; + if (highlight.Value == W.HighlightColorValues.Blue) return PdfCore.PdfColor.FromRgb(0, 0, 255); + if (highlight.Value == W.HighlightColorValues.Cyan) return PdfCore.PdfColor.FromRgb(0, 255, 255); + if (highlight.Value == W.HighlightColorValues.Green) return PdfCore.PdfColor.FromRgb(0, 255, 0); + if (highlight.Value == W.HighlightColorValues.Magenta) return PdfCore.PdfColor.FromRgb(255, 0, 255); + if (highlight.Value == W.HighlightColorValues.Red) return PdfCore.PdfColor.FromRgb(255, 0, 0); + if (highlight.Value == W.HighlightColorValues.Yellow) return PdfCore.PdfColor.FromRgb(255, 255, 0); + if (highlight.Value == W.HighlightColorValues.White) return PdfCore.PdfColor.White; + if (highlight.Value == W.HighlightColorValues.DarkBlue) return PdfCore.PdfColor.FromRgb(0, 0, 139); + if (highlight.Value == W.HighlightColorValues.DarkCyan) return PdfCore.PdfColor.FromRgb(0, 139, 139); + if (highlight.Value == W.HighlightColorValues.DarkGreen) return PdfCore.PdfColor.FromRgb(0, 100, 0); + if (highlight.Value == W.HighlightColorValues.DarkMagenta) return PdfCore.PdfColor.FromRgb(139, 0, 139); + if (highlight.Value == W.HighlightColorValues.DarkRed) return PdfCore.PdfColor.FromRgb(139, 0, 0); + if (highlight.Value == W.HighlightColorValues.DarkYellow) return PdfCore.PdfColor.FromRgb(184, 134, 11); + if (highlight.Value == W.HighlightColorValues.LightGray) return PdfCore.PdfColor.FromRgb(211, 211, 211); + if (highlight.Value == W.HighlightColorValues.DarkGray) return PdfCore.PdfColor.FromRgb(169, 169, 169); + + return null; + } + } +} diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Rendering.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Rendering.cs deleted file mode 100644 index f6babdd38..000000000 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Rendering.cs +++ /dev/null @@ -1,474 +0,0 @@ -using DocumentFormat.OpenXml.Vml; -using QuestPDF.Drawing; -using QuestPDF.Fluent; -using QuestPDF.Infrastructure; -using SkiaSharp; -using System.Runtime.InteropServices; -using System.Collections.Generic; -using System.Globalization; -using System.Text; -using W = DocumentFormat.OpenXml.Wordprocessing; - -namespace OfficeIMO.Word.Pdf { - public static partial class WordPdfConverterExtensions { - static readonly HashSet _embeddedFonts = new(); - - static void EmbedFont(string? fontFamily) { - if (string.IsNullOrWhiteSpace(fontFamily)) { - return; - } - if (_embeddedFonts.Contains(fontFamily!)) { - return; - } - try { - bool registered = false; - using SKTypeface? typeface = SKTypeface.FromFamilyName(fontFamily); - using SKStreamAsset? skStream = MatchesRequestedFamily(typeface, fontFamily!) ? typeface?.OpenStream() : null; - if (skStream != null) { - using MemoryStream ms = new(); - if (skStream.HasLength) { - byte[] buffer = new byte[skStream.Length]; - skStream.Read(buffer, buffer.Length); - ms.Write(buffer, 0, buffer.Length); - } else { - byte[] buffer = new byte[4096]; - int read; - while ((read = skStream.Read(buffer, buffer.Length)) > 0) { - ms.Write(buffer, 0, read); - } - } - ms.Position = 0; - FontManager.RegisterFontWithCustomName(fontFamily!, ms); - registered = true; - _embeddedFonts.Add(fontFamily!); - return; - } - - // Fallback: try to locate system font files cross-platform - string? path = TryResolveSystemFontFile(fontFamily!); - if (!string.IsNullOrEmpty(path) && File.Exists(path)) { - using var fs = File.OpenRead(path); - FontManager.RegisterFontWithCustomName(fontFamily!, fs); - registered = true; - } - - if (registered) { - _embeddedFonts.Add(fontFamily!); - } - } catch { - } - } - - static bool MatchesRequestedFamily(SKTypeface? typeface, string requestedFamily) { - if (typeface == null || string.IsNullOrWhiteSpace(typeface.FamilyName)) { - return false; - } - - string resolved = NormalizeFontFamily(typeface.FamilyName); - string requested = NormalizeFontFamily(requestedFamily); - return resolved.Equals(requested, StringComparison.OrdinalIgnoreCase) || - resolved.StartsWith(requested, StringComparison.OrdinalIgnoreCase) || - requested.StartsWith(resolved, StringComparison.OrdinalIgnoreCase); - } - - static string NormalizeFontFamily(string family) { - var sb = new StringBuilder(family.Length); - foreach (char c in family) { - if (char.IsLetterOrDigit(c)) { - sb.Append(c); - } - } - - return sb.ToString(); - } - - static string? TryResolveSystemFontFile(string family) { - try { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - var fontsDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); - if (!string.IsNullOrEmpty(fontsDir) && Directory.Exists(fontsDir)) { - // Common Arial files - string[] candidates = new[] { "arial.ttf", "arial.ttf", "ARIAL.TTF", "Arial.ttf", "arialmt.ttf", "ARIALMT.TTF" }; - foreach (var c in candidates) { - var p = System.IO.Path.Combine(fontsDir, c); - if (File.Exists(p)) return p; - } - // Last resort: search for files containing family name - var file = Directory.EnumerateFiles(fontsDir, "*.ttf").Concat(Directory.EnumerateFiles(fontsDir, "*.otf")).FirstOrDefault(f => System.IO.Path.GetFileNameWithoutExtension(f).IndexOf(family, StringComparison.OrdinalIgnoreCase) >= 0); - if (file != null) return file; - } - } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - string[] paths = new[] { - "/System/Library/Fonts/Supplemental/Arial.ttf", - "/Library/Fonts/Arial.ttf", - "/System/Library/Fonts/Arial.ttf" - }; - foreach (var p in paths) if (File.Exists(p)) return p; - } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - // DejaVu Sans is most common - string[] roots = new[] { "/usr/share/fonts", "/usr/local/share/fonts", System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fonts") }; - foreach (var r in roots) { - if (!Directory.Exists(r)) continue; - var file = Directory.EnumerateFiles(r, "*", SearchOption.AllDirectories) - .FirstOrDefault(f => System.IO.Path.GetFileName(f).EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) && System.IO.Path.GetFileNameWithoutExtension(f).IndexOf(family.Replace(" ", string.Empty), StringComparison.OrdinalIgnoreCase) >= 0); - if (file != null) return file; - // Specific fallback for DejaVu Sans - var dv = Directory.EnumerateFiles(r, "DejaVuSans.ttf", SearchOption.AllDirectories).FirstOrDefault(); - if (dv != null && family.IndexOf("DejaVu", StringComparison.OrdinalIgnoreCase) >= 0) return dv; - } - } - } catch { } - return null; - } - static void RenderElement(ColumnDescriptor column, WordElement element, Func getMarker, PdfSaveOptions? options, Dictionary footnoteMap) { - switch (element) { - case WordParagraph paragraph: - column.Item().Element(e => RenderParagraph(e, paragraph, getMarker(paragraph), options, footnoteMap)); - break; - case WordTable table: - column.Item().Element(e => RenderTable(e, table, getMarker, options, footnoteMap)); - break; - case WordImage image: - column.Item().Element(e => RenderImage(e, image)); - break; - case WordHyperLink link: - column.Item().Element(e => RenderHyperLink(e, link)); - break; - case WordShape shape: - column.Item().Element(e => RenderShape(e, shape)); - break; - } - } - static IContainer RenderParagraph(IContainer container, WordParagraph paragraph, (int Level, string Marker)? marker, PdfSaveOptions? options, Dictionary footnoteMap) { - if (paragraph == null) { - return container; - } - - if (!string.IsNullOrEmpty(paragraph.Bookmark?.Name)) { - container = container.Section(paragraph.Bookmark!.Name!); - } - - if (paragraph.IsHyperLink && paragraph.Hyperlink != null) { - var link = paragraph.Hyperlink; - if (!string.IsNullOrEmpty(link.Anchor)) { - container = container.SectionLink(link.Anchor!); - } else if (link.Uri != null) { - container = container.Hyperlink(link.Uri.ToString()); - } - } - - if (paragraph.ParagraphAlignment == W.JustificationValues.Center) { - container = container.AlignCenter(); - } else if (paragraph.ParagraphAlignment == W.JustificationValues.Right) { - container = container.AlignRight(); - } else if (paragraph.ParagraphAlignment == W.JustificationValues.Both) { - container = container.AlignLeft(); - } - - // Paragraph background and borders - if (!string.IsNullOrEmpty(paragraph.ShadingFillColorHex)) { - container = container.Background("#" + paragraph.ShadingFillColorHex); - } - container = ApplyParagraphBorders(container, paragraph); - - int? currentFootnoteNumber = null; - if (footnoteMap.TryGetValue(paragraph, out int num)) { - currentFootnoteNumber = num; - } - - container.Column(col => { - if (paragraph.Image != null) { - col.Item().Element(imageContainer => { - var img = paragraph.Image; - var sized = imageContainer; - if (img.Width.HasValue) { - sized = sized.Width((float)(img.Width.Value * 72 / 96)); - } - if (img.Height.HasValue) { - sized = sized.Height((float)(img.Height.Value * 72 / 96)); - } - sized.Image(ImageEmbedder.GetImageBytes(img)); - }); - } - - var runObjs = paragraph.GetRuns().ToList(); - // Prefer run-accurate rendering when available; otherwise fall back to paragraph text - string content = paragraph.IsHyperLink && paragraph.Hyperlink != null ? paragraph.Hyperlink.Text : paragraph.Text; - bool hasRenderableRuns = runObjs.Count > 0 && runObjs.Any(r => r.IsImage || !string.IsNullOrEmpty(r.Text)); - if (hasRenderableRuns || !string.IsNullOrEmpty(content) || marker != null) { - if (marker != null) { - const float indentSize = 15f; - col.Item().Row(row => { - if (marker.Value.Level > 0) { - row.ConstantItem(indentSize * marker.Value.Level); - } - row.AutoItem().Text(marker.Value.Marker + " "); - row.RelativeItem().Text(text => { - if (hasRenderableRuns) { - foreach (var run in runObjs) { - if (run.IsImage) continue; // images handled separately above - if (string.IsNullOrEmpty(run.Text)) continue; - var span = text.Span(run.Text!); - // apply paragraph defaults first, then run overrides - ApplyFormatting(span); - ApplyRunFormatting(ref span, run, options); - } - } else { - ApplyFormatting(text.Span(content)); - } - if (currentFootnoteNumber != null) { - text.Span(currentFootnoteNumber.Value.ToString()).FontSize(8).Superscript(); - } - }); - }); - } else { - col.Item().Text(text => { - if (hasRenderableRuns) { - foreach (var run in runObjs) { - if (run.IsImage) continue; // images handled above - if (string.IsNullOrEmpty(run.Text)) continue; - var span = text.Span(run.Text!); - // apply paragraph defaults first, then run overrides - ApplyFormatting(span); - ApplyRunFormatting(ref span, run, options); - } - } else { - ApplyFormatting(text.Span(content)); - } - if (currentFootnoteNumber != null) { - text.Span(currentFootnoteNumber.Value.ToString()).FontSize(8).Superscript(); - } - }); - } - } - }); - - return container; - - string? ResolveRegisteredFamily(string? name) { - string? resolved = ResolveRegisteredFontFamily(name); - if (!string.Equals(resolved, name, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(name)) { - return resolved; - } - - string fontName = name ?? string.Empty; - if (fontName.Length > 0 && - options?.FontFilePaths != null && - options.FontFilePaths.TryGetValue(fontName, out string? path) && - !string.IsNullOrWhiteSpace(path) && - File.Exists(path)) { - string? familyName = null; - try { - familyName = TryReadFontFamily(path, File.ReadAllBytes(path)); - } catch { - } - - if (!string.IsNullOrWhiteSpace(familyName)) { - return familyName; - } - } - - return resolved; - } - - void ApplyFormatting(TextSpanDescriptor span) { - if (!string.IsNullOrEmpty(paragraph.FontFamily)) { - var fam = ResolveRegisteredFamily(paragraph.FontFamily!); - EmbedFont(fam); - span = span.FontFamily(fam!); - } else if (!string.IsNullOrEmpty(options?.FontFamily)) { - var defFont = ResolveRegisteredFamily(options!.FontFamily!); - EmbedFont(defFont); - span = span.FontFamily(defFont!); - } - if (paragraph.Bold) { - span = span.Bold(); - } - if (paragraph.Italic) { - span = span.Italic(); - } - if (paragraph.Underline != null) { - span = span.Underline(); - } - if (!string.IsNullOrEmpty(paragraph.ColorHex)) { - span = span.FontColor("#" + paragraph.ColorHex); - } - if (paragraph.Style.HasValue) { - switch (paragraph.Style.Value) { - case WordParagraphStyles.Heading1: - span.FontSize(24).Bold(); - break; - case WordParagraphStyles.Heading2: - span.FontSize(20).Bold(); - break; - case WordParagraphStyles.Heading3: - span.FontSize(16).Bold(); - break; - case WordParagraphStyles.Heading4: - span.FontSize(14).Bold(); - break; - case WordParagraphStyles.Heading5: - span.FontSize(13).Bold(); - break; - case WordParagraphStyles.Heading6: - span.FontSize(12).Bold(); - break; - } - } - } - - static string? MapHighlight(W.HighlightColorValues? highlight) { - if (!highlight.HasValue) return null; - var v = highlight.Value; - if (v == W.HighlightColorValues.None) return null; - if (v == W.HighlightColorValues.Black) return "#000000"; - if (v == W.HighlightColorValues.Blue) return "#0000ff"; - if (v == W.HighlightColorValues.Cyan) return "#00ffff"; - if (v == W.HighlightColorValues.Green) return "#00ff00"; - if (v == W.HighlightColorValues.Magenta) return "#ff00ff"; - if (v == W.HighlightColorValues.Red) return "#ff0000"; - if (v == W.HighlightColorValues.Yellow) return "#ffff00"; - if (v == W.HighlightColorValues.White) return "#ffffff"; - if (v == W.HighlightColorValues.DarkBlue) return "#00008b"; - if (v == W.HighlightColorValues.DarkCyan) return "#008b8b"; - if (v == W.HighlightColorValues.DarkGreen) return "#006400"; - if (v == W.HighlightColorValues.DarkMagenta) return "#8b008b"; - if (v == W.HighlightColorValues.DarkRed) return "#8b0000"; - if (v == W.HighlightColorValues.DarkYellow) return "#b8860b"; - if (v == W.HighlightColorValues.LightGray) return "#d3d3d3"; - if (v == W.HighlightColorValues.DarkGray) return "#a9a9a9"; - return null; - } - - void ApplyRunFormatting(ref TextSpanDescriptor span, WordParagraph run, PdfSaveOptions? opt) { - if (string.IsNullOrEmpty(run.Text)) return; - if (run.Bold) span = span.Bold(); - if (run.Italic) span = span.Italic(); - if (run.Underline != null) span = span.Underline(); - if (run.Strike || run.DoubleStrike) span = span.Strikethrough(); - if (run.VerticalTextAlignment == W.VerticalPositionValues.Superscript) span = span.Superscript(); - if (run.VerticalTextAlignment == W.VerticalPositionValues.Subscript) span = span.Subscript(); - // Inline hyperlink on text spans is not supported by QuestPDF directly. - // Paragraph-level hyperlinks are applied earlier; skip span-level link here. - // Preserve paragraph/default fonts unless the run explicitly overrides them. - if (!string.IsNullOrEmpty(run.FontFamily)) { - var fam = ResolveRegisteredFamily(run.FontFamily)!; - EmbedFont(fam); - span = span.FontFamily(fam); - } - if (run.FontSize != null) - span.FontSize(run.FontSize.Value); - if (!string.IsNullOrEmpty(run.ColorHex)) span = span.FontColor("#" + run.ColorHex); - var hl = MapHighlight(run.Highlight); - if (!string.IsNullOrEmpty(hl)) span = span.BackgroundColor(hl!); - } - - static IContainer ApplyParagraphBorders(IContainer cont, WordParagraph p) { - var b = p.Borders; - if (b == null) return cont; - - // Determine a uniform color if possible - var colors = new List { b.TopColorHex, b.BottomColorHex, b.LeftColorHex, b.RightColorHex }; - colors.RemoveAll(string.IsNullOrEmpty); - if (colors.Count > 0 && colors.Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1) { - cont = cont.BorderColor("#" + colors[0]!); - } - - float BorderWidth(uint? size) => size.HasValue ? size.Value / 8f : 0f; - if (b.TopStyle != null && b.TopStyle != W.BorderValues.Nil && b.TopStyle != W.BorderValues.None) cont = cont.BorderTop(BorderWidth(b.TopSize?.Value)); - if (b.BottomStyle != null && b.BottomStyle != W.BorderValues.Nil && b.BottomStyle != W.BorderValues.None) cont = cont.BorderBottom(BorderWidth(b.BottomSize?.Value)); - if (b.LeftStyle != null && b.LeftStyle != W.BorderValues.Nil && b.LeftStyle != W.BorderValues.None) cont = cont.BorderLeft(BorderWidth(b.LeftSize?.Value)); - if (b.RightStyle != null && b.RightStyle != W.BorderValues.Nil && b.RightStyle != W.BorderValues.None) cont = cont.BorderRight(BorderWidth(b.RightSize?.Value)); - return cont; - } - } - - static IContainer RenderImage(IContainer container, WordImage image) { - if (image == null) { - return container; - } - - var sized = container; - if (image.Width.HasValue) { - sized = sized.Width((float)(image.Width.Value * 72 / 96)); - } - if (image.Height.HasValue) { - sized = sized.Height((float)(image.Height.Value * 72 / 96)); - } - sized.Image(ImageEmbedder.GetImageBytes(image)); - - return container; - } - - static IContainer RenderHyperLink(IContainer container, WordHyperLink link) { - if (link == null) { - return container; - } - - if (!string.IsNullOrEmpty(link.Anchor)) { - container = container.SectionLink(link.Anchor!); - } else if (link.Uri != null) { - container = container.Hyperlink(link.Uri.ToString()); - } - - container.Text(link.Text); - - return container; - } - - static IContainer RenderShape(IContainer container, WordShape shape) { - if (shape == null) { - return container; - } - float width = (float)shape.Width; - float height = (float)shape.Height; - - string? fill = shape.FillColorHex; - string? stroke = shape.StrokeColorHex; - float strokeWidth = (float)(shape.StrokeWeight ?? 1); - bool drawStroke = shape.Stroked ?? false; - - var run = shape.Run; - string text = run?.InnerText ?? string.Empty; - - var line = shape.Line; - - if (line != null) { - (float x1, float y1) = ParsePoint(line.From?.Value ?? "0pt,0pt"); - (float x2, float y2) = ParsePoint(line.To?.Value ?? "0pt,0pt"); - - string svg = $""; - - container.Svg(svg); - - return container; - } - - container = container.Width(width).Height(height); - - if (!string.IsNullOrEmpty(fill)) { - container = container.Background("#" + fill); - } - - if (drawStroke) { - container = container.BorderColor("#" + (stroke ?? "000000")).Border(strokeWidth); - } - - if (!string.IsNullOrWhiteSpace(text)) { - container = container.AlignCenter().AlignMiddle(); - container.Text(text); - } - - return container; - - static (float, float) ParsePoint(string value) { - var parts = value.Split(','); - return (Parse(parts[0]), Parse(parts[1])); - } - - static float Parse(string value) { - return (float)double.Parse(value.Replace("pt", string.Empty), CultureInfo.InvariantCulture); - } - } - } -} diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Tables.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Tables.cs deleted file mode 100644 index 99e93d2e1..000000000 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Tables.cs +++ /dev/null @@ -1,101 +0,0 @@ -using DocumentFormat.OpenXml; -using QuestPDF.Fluent; -using QuestPDF.Infrastructure; -using System.Collections.Generic; -using W = DocumentFormat.OpenXml.Wordprocessing; - -namespace OfficeIMO.Word.Pdf { - public static partial class WordPdfConverterExtensions { - private static IContainer RenderTable(IContainer container, WordTable table, Func getMarker, PdfSaveOptions? options, Dictionary footnoteMap) { - container.Table(tableContainer => { - TableLayout layout = TableLayoutCache.GetLayout(table); - tableContainer.ColumnsDefinition(columns => { - foreach (float width in layout.ColumnWidths) { - if (width > 0) { - columns.ConstantColumn(width); - } else { - columns.RelativeColumn(); - } - } - }); - - foreach (IReadOnlyList row in layout.Rows) { - foreach (WordTableCell cell in row) { - tableContainer.Cell().Element(cellContainer => { - cellContainer = ApplyCellStyle(cellContainer, cell, options); - - cellContainer.Column(cellColumn => { - foreach (WordParagraph paragraph in cell.Paragraphs) { - cellColumn.Item().Element(e => { - return RenderParagraph(e, paragraph, getMarker(paragraph), options, footnoteMap); - }); - } - - foreach (WordTable nested in cell.NestedTables) { - cellColumn.Item().Element(e => RenderTable(e, nested, getMarker, options, footnoteMap)); - } - }); - - return cellContainer; - }); - } - } - }); - - return container; - } - - private static IContainer ApplyCellStyle(IContainer container, WordTableCell cell, PdfSaveOptions? options) { - if (!string.IsNullOrEmpty(cell.ShadingFillColorHex)) { - // Ignore automatic color to let QuestPDF use its own defaults. - if (!cell.ShadingFillColorHex.Equals("auto", StringComparison.OrdinalIgnoreCase)) { - container = container.Background("#" + cell.ShadingFillColorHex); - } - } - - WordTableCellBorder borders = cell.Borders; - - List colors = new() - { - borders.TopColorHex, - borders.BottomColorHex, - borders.LeftColorHex, - borders.RightColorHex - }; - // Filter out empty and automatic colors – QuestPDF expects real hex values. - colors.RemoveAll(c => - string.IsNullOrEmpty(c) || string.Equals(c, "auto", StringComparison.OrdinalIgnoreCase)); - if (colors.Count > 0 && colors.Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1) { - container = container.BorderColor("#" + colors[0]!); - } - - bool anyBorder = false; - if (HasBorder(borders.TopStyle)) { - container = container.BorderTop(GetBorderWidth(borders.TopSize)); - anyBorder = true; - } - if (HasBorder(borders.BottomStyle)) { - container = container.BorderBottom(GetBorderWidth(borders.BottomSize)); - anyBorder = true; - } - if (HasBorder(borders.LeftStyle)) { - container = container.BorderLeft(GetBorderWidth(borders.LeftSize)); - anyBorder = true; - } - if (HasBorder(borders.RightStyle)) { - container = container.BorderRight(GetBorderWidth(borders.RightSize)); - anyBorder = true; - } - - if (!anyBorder && options?.DefaultTableBorders == true) { - container = container.Border(0.75f).BorderColor("#d6d6d6"); - } - - return container; - } - - private static bool HasBorder(W.BorderValues? style) => style != null && style != W.BorderValues.Nil && style != W.BorderValues.None; - - private static float GetBorderWidth(UInt32Value? size) => size != null ? size.Value / 8f : 1f; - } -} diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.cs index acdf98448..efa11f92e 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.cs @@ -1,17 +1,5 @@ -using QuestPDF.Drawing; -using QuestPDF.Fluent; -using QuestPDF.Helpers; -using QuestPDF.Infrastructure; -using SkiaSharp; -using System.Collections.Generic; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using System.Reflection; -#if !(NET472 || NET48 || NETSTANDARD2_0) -using System.Runtime.Loader; -#endif -using W = DocumentFormat.OpenXml.Wordprocessing; namespace OfficeIMO.Word.Pdf { @@ -19,8 +7,6 @@ namespace OfficeIMO.Word.Pdf { /// Provides extension methods for converting instances to PDF files. /// public static partial class WordPdfConverterExtensions { - static readonly Dictionary _registeredCustomFontFamilies = new(StringComparer.OrdinalIgnoreCase); - /// /// Saves the specified as a PDF at the given . /// @@ -46,14 +32,7 @@ public static void SaveAsPdf(this WordDocument document, string path, PdfSaveOpt Directory.CreateDirectory(directory); } - var originalLicense = QuestPdfLicenseUtil.GetEffectiveLicenseValue(); - try { - Document pdf = CreatePdfDocument(document, options); - pdf.GeneratePdf(fullPath); - } finally { - // Restore whatever license was set before conversion across all loaded QuestPDF TFMs - RestoreQuestPdfLicense(originalLicense); - } + CreateOfficeIMOPdfDocument(document, options).Save(fullPath); } /// @@ -75,16 +54,9 @@ public static void SaveAsPdf(this WordDocument document, Stream stream, PdfSaveO throw new ArgumentException("Stream must be writable.", nameof(stream)); } - var originalLicense = QuestPdfLicenseUtil.GetEffectiveLicenseValue(); - try { - Document pdf = CreatePdfDocument(document, options); - pdf.GeneratePdf(stream); - - if (stream.CanSeek) { - stream.Position = 0; - } - } finally { - RestoreQuestPdfLicense(originalLicense); + CreateOfficeIMOPdfDocument(document, options).Save(stream); + if (stream.CanSeek) { + stream.Position = 0; } } @@ -99,16 +71,7 @@ public static byte[] SaveAsPdf(this WordDocument document, PdfSaveOptions? optio throw new ArgumentNullException(nameof(document)); } - var originalLicense = QuestPdfLicenseUtil.GetEffectiveLicenseValue(); - try { - using (MemoryStream stream = new MemoryStream()) { - Document pdf = CreatePdfDocument(document, options); - pdf.GeneratePdf(stream); - return stream.ToArray(); - } - } finally { - RestoreQuestPdfLicense(originalLicense); - } + return CreateOfficeIMOPdfDocument(document, options).ToBytes(); } /// @@ -123,17 +86,10 @@ public static async Task SaveAsPdfAsync(this WordDocument document, PdfS throw new ArgumentNullException(nameof(document)); } - var originalLicense = QuestPdfLicenseUtil.GetEffectiveLicenseValue(); - try { - using (MemoryStream stream = new MemoryStream()) { - cancellationToken.ThrowIfCancellationRequested(); - Document pdf = CreatePdfDocument(document, options); - await Task.Run(() => pdf.GeneratePdf(stream), cancellationToken).ConfigureAwait(false); - stream.Position = 0; - return stream.ToArray(); - } - } finally { - RestoreQuestPdfLicense(originalLicense); + cancellationToken.ThrowIfCancellationRequested(); + using (MemoryStream stream = new MemoryStream()) { + await CreateOfficeIMOPdfDocument(document, options).SaveAsync(stream, cancellationToken).ConfigureAwait(false); + return stream.ToArray(); } } @@ -165,13 +121,7 @@ public static async Task SaveAsPdfAsync(this WordDocument document, string path, Directory.CreateDirectory(directory); } - var originalLicense = QuestPdfLicenseUtil.GetEffectiveLicenseValue(); - try { - Document pdf = CreatePdfDocument(document, options); - await Task.Run(() => pdf.GeneratePdf(fullPath), cancellationToken).ConfigureAwait(false); - } finally { - RestoreQuestPdfLicense(originalLicense); - } + await CreateOfficeIMOPdfDocument(document, options).SaveAsync(fullPath, cancellationToken).ConfigureAwait(false); } /// @@ -197,23 +147,12 @@ public static async Task SaveAsPdfAsync(this WordDocument document, Stream strea throw new ArgumentException("Stream must be writable.", nameof(stream)); } - var originalLicense = QuestPdfLicenseUtil.GetEffectiveLicenseValue(); - try { - Document pdf = CreatePdfDocument(document, options); - await Task.Run(() => pdf.GeneratePdf(stream), cancellationToken).ConfigureAwait(false); - if (stream.CanSeek) { - stream.Position = 0; - } - } finally { - RestoreQuestPdfLicense(originalLicense); + await CreateOfficeIMOPdfDocument(document, options).SaveAsync(stream, cancellationToken).ConfigureAwait(false); + if (stream.CanSeek) { + stream.Position = 0; } } - private static void RestoreQuestPdfLicense(int? originalLicense) { - QuestPdfLicenseUtil.SetLicenseForAll(originalLicense); - QuestPDF.Settings.License = originalLicense.HasValue ? (LicenseType?) (LicenseType)originalLicense.Value : null; - } - private static string ValidateOutputPath(string path, string paramName) { string fullPath; try { @@ -237,468 +176,5 @@ private static string ValidateOutputPath(string path, string paramName) { return fullPath; } - - private static Document CreatePdfDocument(WordDocument document, PdfSaveOptions? options) { - // Respect an existing license from any loaded QuestPDF TFM; only set when none is present - if (QuestPdfLicenseUtil.GetEffectiveLicenseValue() == null) { - var desired = options?.QuestPdfLicenseType ?? LicenseType.Community; - // Set on all loaded QuestPDF TFMs - QuestPdfLicenseUtil.SetLicenseForAll((int)desired); - // And set directly on the currently referenced assembly as a reliable fallback - QuestPDF.Settings.License = desired; - } - - RegisterFonts(options); - - BuiltinDocumentProperties properties = document.BuiltinDocumentProperties; - Dictionary listMarkers = DocumentTraversal.BuildListMarkers(document); - - Document pdf = Document.Create(container => { - foreach (WordSection section in document.Sections) { - container.Page(page => { - if (options?.FontFamily is { Length: > 0 } fontFamily) { - page.DefaultTextStyle(t => t.FontFamily(fontFamily)); - } - - if (options?.MarginLeft != null || options?.MarginRight != null || options?.MarginTop != null || options?.MarginBottom != null) { - float left = options.MarginLeft ?? options.Margin ?? (section.Margins.Left?.Value ?? 0) / 20f; - Unit leftUnit = options.MarginLeft != null ? options.MarginLeftUnit : options.Margin != null ? options.MarginUnit : Unit.Point; - float right = options.MarginRight ?? options.Margin ?? (section.Margins.Right?.Value ?? 0) / 20f; - Unit rightUnit = options.MarginRight != null ? options.MarginRightUnit : options.Margin != null ? options.MarginUnit : Unit.Point; - float top = options.MarginTop ?? options.Margin ?? (section.Margins.Top ?? 0) / 20f; - Unit topUnit = options.MarginTop != null ? options.MarginTopUnit : options.Margin != null ? options.MarginUnit : Unit.Point; - float bottom = options.MarginBottom ?? options.Margin ?? (section.Margins.Bottom ?? 0) / 20f; - Unit bottomUnit = options.MarginBottom != null ? options.MarginBottomUnit : options.Margin != null ? options.MarginUnit : Unit.Point; - - page.MarginLeft(left, leftUnit); - page.MarginRight(right, rightUnit); - page.MarginTop(top, topUnit); - page.MarginBottom(bottom, bottomUnit); - } else if (options?.Margin != null) { - page.Margin(options.Margin.Value, options.MarginUnit); - } else { - float leftMargin = (section.Margins.Left?.Value ?? 0) / 20f; - float rightMargin = (section.Margins.Right?.Value ?? 0) / 20f; - float topMargin = (section.Margins.Top ?? 0) / 20f; - float bottomMargin = (section.Margins.Bottom ?? 0) / 20f; - page.MarginLeft(leftMargin, Unit.Point); - page.MarginRight(rightMargin, Unit.Point); - page.MarginTop(topMargin, Unit.Point); - page.MarginBottom(bottomMargin, Unit.Point); - } - - PageSize size; - if (options?.PageSize != null) { - size = options.PageSize; - } else if (section.PageSettings.PageSize.HasValue) { - size = MapToPageSize(section.PageSettings.PageSize.Value); - } else if (options?.DefaultPageSize.HasValue == true) { - size = MapToPageSize(options.DefaultPageSize.Value); - } else { - size = PageSizes.A4; - } - - PdfPageOrientation orientation; - if (options?.Orientation != null) { - orientation = options.Orientation.Value; - } else if (section.PageSettings.Orientation == W.PageOrientationValues.Landscape) { - orientation = PdfPageOrientation.Landscape; - } else if (options?.DefaultOrientation != null) { - orientation = options.DefaultOrientation == W.PageOrientationValues.Landscape ? PdfPageOrientation.Landscape : PdfPageOrientation.Portrait; - } else { - orientation = PdfPageOrientation.Portrait; - } - - if (orientation == PdfPageOrientation.Landscape) { - size = size.Landscape(); - } else { - size = size.Portrait(); - } - - page.Size(size); - - List footnotes = new(); - Dictionary footnoteMap = new(); - int footnoteCounter = 1; - - CollectFootnotes(section.Elements, footnotes, footnoteMap, ref footnoteCounter); - - RenderHeader(page, section, footnotes, footnoteMap); - - page.Content().Column(column => { - foreach (WordElement element in section.Elements) { - RenderElement(column, element, GetMarker, options, footnoteMap); - } - }); - - RenderFooter(page, section, footnotes, footnoteMap); - }); - } - }) - .WithMetadata(new DocumentMetadata { - Title = options?.Title ?? properties.Title, - Author = options?.Author ?? properties.Creator, - Subject = options?.Subject ?? properties.Subject, - Keywords = BuildKeywords(options, properties) - }); - - static string? BuildKeywords(PdfSaveOptions? opts, BuiltinDocumentProperties props) { - string? keys = opts?.Keywords ?? props.Keywords; - var fam = opts?.FontFamily; - if (!string.IsNullOrWhiteSpace(fam)) { - keys = string.IsNullOrWhiteSpace(keys) ? fam : (keys + ";" + fam); - } - return keys; - } - - return pdf; - - (int Level, string Marker)? GetMarker(WordParagraph paragraph) { - if (listMarkers.TryGetValue(paragraph, out var value)) { - return value; - } - - return null; - } - - void RenderElements(ColumnDescriptor column, IEnumerable paragraphs, IEnumerable tables, IEnumerable images, IEnumerable links, Dictionary footnoteMap) { - foreach (WordParagraph paragraph in paragraphs) { - RenderElement(column, paragraph, GetMarker, options, footnoteMap); - } - - foreach (WordTable table in tables) { - RenderElement(column, table, GetMarker, options, footnoteMap); - } - - foreach (WordImage image in images) { - RenderElement(column, image, GetMarker, options, footnoteMap); - } - - foreach (WordHyperLink link in links) { - RenderElement(column, link, GetMarker, options, footnoteMap); - } - } - - void RenderHeader(PageDescriptor page, WordSection section, List footnotes, Dictionary footnoteMap) { - if (section.Header == null) return; - bool hasContent = - (section.Header.Default != null && (section.Header.Default.Paragraphs.Count > 0 || section.Header.Default.Tables.Count > 0 || section.Header.Default.Images.Count > 0 || section.Header.Default.HyperLinks.Count > 0)) || - (section.Header.First != null && (section.Header.First.Paragraphs.Count > 0 || section.Header.First.Tables.Count > 0 || section.Header.First.Images.Count > 0 || section.Header.First.HyperLinks.Count > 0)) || - (section.Header.Even != null && (section.Header.Even.Paragraphs.Count > 0 || section.Header.Even.Tables.Count > 0 || section.Header.Even.Images.Count > 0 || section.Header.Even.HyperLinks.Count > 0)); - if (!hasContent) return; - - page.Header().Layers(layers => { - if (section.Header.Default != null && (section.Header.Default.Paragraphs.Count > 0 || section.Header.Default.Tables.Count > 0 || section.Header.Default.Images.Count > 0 || section.Header.Default.HyperLinks.Count > 0)) { - layers.PrimaryLayer().ShowIf(x => (section.Header.First == null || x.PageNumber > 1) && (section.Header.Even == null || x.PageNumber % 2 == 1)).Column(col => { - RenderElements(col, section.Header.Default.Paragraphs, section.Header.Default.Tables, section.Header.Default.Images, section.Header.Default.HyperLinks, footnoteMap); - }); - } - - if (section.Header.First != null && (section.Header.First.Paragraphs.Count > 0 || section.Header.First.Tables.Count > 0 || section.Header.First.Images.Count > 0 || section.Header.First.HyperLinks.Count > 0)) { - layers.Layer().ShowIf(x => x.PageNumber == 1).Column(col => { - RenderElements(col, section.Header.First.Paragraphs, section.Header.First.Tables, section.Header.First.Images, section.Header.First.HyperLinks, footnoteMap); - }); - } - - if (section.Header.Even != null && (section.Header.Even.Paragraphs.Count > 0 || section.Header.Even.Tables.Count > 0 || section.Header.Even.Images.Count > 0 || section.Header.Even.HyperLinks.Count > 0)) { - layers.Layer().ShowIf(x => x.PageNumber % 2 == 0 && x.PageNumber > 1).Column(col => { - RenderElements(col, section.Header.Even.Paragraphs, section.Header.Even.Tables, section.Header.Even.Images, section.Header.Even.HyperLinks, footnoteMap); - }); - } - }); - } - - void RenderFooter(PageDescriptor page, WordSection section, List footnotes, Dictionary footnoteMap) { - bool includePageNumbers = options?.IncludePageNumbers ?? true; - if (section.Footer == null && footnotes.Count == 0 && !includePageNumbers) { - return; - } - - bool hasContent = - (section.Footer?.Default != null && (section.Footer.Default.Paragraphs.Count > 0 || section.Footer.Default.Tables.Count > 0 || section.Footer.Default.Images.Count > 0 || section.Footer.Default.HyperLinks.Count > 0)) || - (section.Footer?.First != null && (section.Footer.First.Paragraphs.Count > 0 || section.Footer.First.Tables.Count > 0 || section.Footer.First.Images.Count > 0 || section.Footer.First.HyperLinks.Count > 0)) || - (section.Footer?.Even != null && (section.Footer.Even.Paragraphs.Count > 0 || section.Footer.Even.Tables.Count > 0 || section.Footer.Even.Images.Count > 0 || section.Footer.Even.HyperLinks.Count > 0)); - - page.Footer().Layers(layers => { - bool primaryDefined = false; - if (section.Footer != null && hasContent) { - if (section.Footer.Default != null && (section.Footer.Default.Paragraphs.Count > 0 || section.Footer.Default.Tables.Count > 0 || section.Footer.Default.Images.Count > 0 || section.Footer.Default.HyperLinks.Count > 0)) { - layers.PrimaryLayer().ShowIf(x => (section.Footer.First == null || x.PageNumber > 1) && (section.Footer.Even == null || x.PageNumber % 2 == 1)).Column(col => { - RenderElements(col, section.Footer.Default.Paragraphs, section.Footer.Default.Tables, section.Footer.Default.Images, section.Footer.Default.HyperLinks, footnoteMap); - }); - primaryDefined = true; - } - - if (section.Footer.First != null && (section.Footer.First.Paragraphs.Count > 0 || section.Footer.First.Tables.Count > 0 || section.Footer.First.Images.Count > 0 || section.Footer.First.HyperLinks.Count > 0)) { - layers.Layer().ShowIf(x => x.PageNumber == 1).Column(col => { - RenderElements(col, section.Footer.First.Paragraphs, section.Footer.First.Tables, section.Footer.First.Images, section.Footer.First.HyperLinks, footnoteMap); - }); - } - - if (section.Footer.Even != null && (section.Footer.Even.Paragraphs.Count > 0 || section.Footer.Even.Tables.Count > 0 || section.Footer.Even.Images.Count > 0 || section.Footer.Even.HyperLinks.Count > 0)) { - layers.Layer().ShowIf(x => x.PageNumber % 2 == 0 && x.PageNumber > 1).Column(col => { - RenderElements(col, section.Footer.Even.Paragraphs, section.Footer.Even.Tables, section.Footer.Even.Images, section.Footer.Even.HyperLinks, footnoteMap); - }); - } - } - - if (!primaryDefined) { - layers.PrimaryLayer(); - } - - if (footnotes.Count > 0) { - layers.Layer().Column(col => { - foreach (var fn in footnotes) { - col.Item().Text($"{fn.Number}. {fn.Text}"); - } - }); - } - - if (includePageNumbers) { - layers.Layer().AlignRight().Text(text => { - string? format = options?.PageNumberFormat; - if (!string.IsNullOrWhiteSpace(format)) { - var tokens = Regex.Split(format, "(\\{current\\}|\\{total\\})"); - foreach (string token in tokens) { - if (token == "{current}") { - text.CurrentPageNumber(); - } else if (token == "{total}") { - text.TotalPages(); - } else if (token.Length > 0) { - text.Span(token); - } - } - } else { - text.CurrentPageNumber(); - text.Span("/"); - text.TotalPages(); - } - }); - } - }); - } - - void CollectFootnotes(IEnumerable elements, List footnotes, Dictionary footnoteMap, ref int footnoteCounter) { - foreach (var element in elements) { - if (element is WordParagraph para) { - var fn = para.FootNote; - if (fn != null) { - footnoteMap[para] = footnoteCounter; - string text = string.Join(" ", fn.Paragraphs?.Select(p => p.Text) ?? Enumerable.Empty()); - footnotes.Add(new PdfFootnote { Number = footnoteCounter, Text = text }); - footnoteCounter++; - } - } else if (element is WordTable t) { - foreach (var row in t.Rows) { - foreach (var cell in row.Cells) { - CollectFootnotes(cell.Paragraphs.Cast(), footnotes, footnoteMap, ref footnoteCounter); - CollectFootnotes(cell.NestedTables.Cast(), footnotes, footnoteMap, ref footnoteCounter); - } - } - } - } - } - } - - // Helpers to read/set QuestPDF license across all loaded TFMs (net8, net9) within the process. - private static class QuestPdfLicenseUtil { - private const string QuestPdfAssemblyName = "QuestPDF"; - private const string SettingsTypeName = "QuestPDF.Infrastructure.Settings"; - private const string LicensePropertyName = "License"; - - public static int? GetEffectiveLicenseValue() { - if (QuestPDF.Settings.License.HasValue) { - return (int)QuestPDF.Settings.License.Value; - } - - foreach (var asm in EnumerateQuestPdfAssemblies()) { - var val = ReadLicenseFromAssembly(asm); - if (val != null) return val; - } - return null; - } - - public static void SetLicenseForAll(int? licenseValue) { - foreach (var asm in EnumerateQuestPdfAssemblies()) { - WriteLicenseToAssembly(asm, licenseValue); - } - } - - private static IEnumerable EnumerateQuestPdfAssemblies() { - // Enumerate currently loaded assemblies in the AppDomain (works on all TFMs) - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { - var name = asm.GetName().Name; - if (string.Equals(name, QuestPdfAssemblyName, StringComparison.Ordinal)) { - yield return asm; - } - } -#if !(NET472 || NET48 || NETSTANDARD2_0) - // Additionally enumerate across AssemblyLoadContexts when available - foreach (var alc in AssemblyLoadContext.All) { - foreach (var asm in alc.Assemblies) { - var name = asm.GetName().Name; - if (string.Equals(name, QuestPdfAssemblyName, StringComparison.Ordinal)) { - yield return asm; - } - } - } -#endif - } - - private static int? ReadLicenseFromAssembly(Assembly asm) { - var settingsType = asm.GetType(SettingsTypeName); - if (settingsType == null) return null; - var prop = settingsType.GetProperty(LicensePropertyName, BindingFlags.Public | BindingFlags.Static); - if (prop == null) return null; - var val = prop.GetValue(null); - if (val == null) return null; - // val is an enum boxed from that asm; convert to int - try { return Convert.ToInt32(val); } catch { return null; } - } - - private static void WriteLicenseToAssembly(Assembly asm, int? licenseValue) { - var settingsType = asm.GetType(SettingsTypeName); - if (settingsType == null) return; - var prop = settingsType.GetProperty(LicensePropertyName, BindingFlags.Public | BindingFlags.Static); - if (prop == null) return; - if (licenseValue == null) { - prop.SetValue(null, null); - return; - } - var enumType = settingsType.Assembly.GetType("QuestPDF.Infrastructure.LicenseType"); - if (enumType == null) return; - var boxed = Enum.ToObject(enumType, licenseValue.Value); - prop.SetValue(null, boxed); - } - } - - private static void RegisterFonts(PdfSaveOptions? options) { - if (options?.FontFilePaths != null) { - foreach (var kvp in options.FontFilePaths) { - if (string.IsNullOrWhiteSpace(kvp.Key) || string.IsNullOrWhiteSpace(kvp.Value)) { - continue; - } - if (!File.Exists(kvp.Value)) { - continue; - } - - using var stream = File.OpenRead(kvp.Value); - TryRegisterFontWithAliases(kvp.Key, stream, kvp.Value); - } - } - - if (options?.FontStreams != null) { - foreach (var kvp in options.FontStreams) { - if (string.IsNullOrWhiteSpace(kvp.Key) || kvp.Value == null) { - continue; - } - if (_embeddedFonts.Contains(kvp.Key)) { - continue; - } - - Stream stream = kvp.Value; - try { - if (stream.CanSeek) { - stream.Position = 0; - } - TryRegisterFontWithAliases(kvp.Key, stream); - } finally { - if (stream.CanSeek) { - stream.Position = 0; - } - } - } - } - } - - private static void TryRegisterFontWithAliases(string alias, Stream stream, string? sourcePath = null) { - byte[] bytes; - using (MemoryStream ms = new()) { - stream.CopyTo(ms); - bytes = ms.ToArray(); - } - - if (bytes.Length == 0) { - return; - } - - RegisterFontData(alias, bytes); - - string? family = TryReadFontFamily(sourcePath, bytes); - if (string.IsNullOrWhiteSpace(family)) { - return; - } - - string familyName = family ?? string.Empty; - if (familyName.Length == 0) { - return; - } - - _registeredCustomFontFamilies[alias] = familyName; - if (!string.Equals(alias, familyName, StringComparison.OrdinalIgnoreCase)) { - RegisterFontData(familyName, bytes); - } - } - - private static void RegisterFontData(string fontName, byte[] bytes) { - if (string.IsNullOrWhiteSpace(fontName) || _embeddedFonts.Contains(fontName)) { - return; - } - - using MemoryStream ms = new(bytes, writable: false); - FontManager.RegisterFontWithCustomName(fontName, ms); - _embeddedFonts.Add(fontName); - } - - private static string? ResolveRegisteredFontFamily(string? fontName) { - if (string.IsNullOrWhiteSpace(fontName)) { - return fontName; - } - - string key = fontName ?? string.Empty; - if (key.Length == 0) { - return fontName; - } - - if (_registeredCustomFontFamilies.TryGetValue(key, out var family) && - !string.IsNullOrWhiteSpace(family)) { - return family; - } - - return key; - } - - private static string? TryReadFontFamily(string? sourcePath, byte[] bytes) { - try { - if (!string.IsNullOrWhiteSpace(sourcePath) && File.Exists(sourcePath)) { - using SKTypeface? fileTypeface = SKTypeface.FromFile(sourcePath); - string? familyName = fileTypeface?.FamilyName; - if (!string.IsNullOrWhiteSpace(familyName)) { - return familyName; - } - } - - using MemoryStream ms = new(bytes, writable: false); - using SKManagedStream skStream = new(ms); - using SKTypeface? typeface = SKTypeface.FromStream(skStream); - string? streamFamilyName = typeface?.FamilyName; - if (!string.IsNullOrWhiteSpace(streamFamilyName)) { - return streamFamilyName; - } - } catch { - } - - return DeriveFontFamilyFromPath(sourcePath); - } - - private static string? DeriveFontFamilyFromPath(string? sourcePath) { - if (string.IsNullOrWhiteSpace(sourcePath)) { - return null; - } - - string familyName = Path.GetFileNameWithoutExtension(sourcePath); - return string.IsNullOrWhiteSpace(familyName) ? null : familyName; - } - } } diff --git a/OfficeIMO.Word/Converters/DocumentTraversal.cs b/OfficeIMO.Word/Converters/DocumentTraversal.cs index 72de59f04..aa3e80ff9 100644 --- a/OfficeIMO.Word/Converters/DocumentTraversal.cs +++ b/OfficeIMO.Word/Converters/DocumentTraversal.cs @@ -19,12 +19,16 @@ public readonly struct ListInfo { /// Starting index for the list. /// Numbering format for the list. /// Raw text pattern defining the marker. - public ListInfo(int level, bool ordered, int start, NumberFormatValues? format, string? text) { + /// List text position in twentieths of a point, when defined. + /// List marker hanging indentation in twentieths of a point, when defined. + public ListInfo(int level, bool ordered, int start, NumberFormatValues? format, string? text, int? leftIndentTwips = null, int? hangingIndentTwips = null) { Level = level; Ordered = ordered; Start = start; NumberFormat = format; LevelText = text; + LeftIndentTwips = leftIndentTwips; + HangingIndentTwips = hangingIndentTwips; } /// Zero-based nesting level. @@ -37,6 +41,10 @@ public ListInfo(int level, bool ordered, int start, NumberFormatValues? format, public NumberFormatValues? NumberFormat { get; } /// Pattern used to build the list marker. public string? LevelText { get; } + /// List text position in twentieths of a point, when defined. + public int? LeftIndentTwips { get; } + /// List marker hanging indentation in twentieths of a point, when defined. + public int? HangingIndentTwips { get; } } /// @@ -61,6 +69,8 @@ public static IEnumerable EnumerateSections(WordDocument document) int start = 1; NumberFormatValues? numberFormat = null; string? levelText = null; + int? leftIndentTwips = null; + int? hangingIndentTwips = null; int? numberId = paragraph._listNumberId; var list = numberId.HasValue ? paragraph._document?.Lists.FirstOrDefault(l => l._numberId == numberId) : null; @@ -96,6 +106,9 @@ public static IEnumerable EnumerateSections(WordDocument document) } numberFormat = wordLevel._level.NumberingFormat?.Val?.Value; levelText = wordLevel.LevelText; + var indentation = wordLevel._level.GetFirstChild()?.GetFirstChild(); + leftIndentTwips = ParseOptionalInt32(indentation?.Left?.Value); + hangingIndentTwips = ParseOptionalInt32(indentation?.Hanging?.Value); } bool ordered = paragraph.ListStyle switch { @@ -103,7 +116,11 @@ public static IEnumerable EnumerateSections(WordDocument document) WordListStyle.BulletedChars => false, _ => true, }; - return new ListInfo(level, ordered, start, numberFormat, levelText); + return new ListInfo(level, ordered, start, numberFormat, levelText, leftIndentTwips, hangingIndentTwips); + } + + private static int? ParseOptionalInt32(string? value) { + return int.TryParse(value, out int parsed) ? parsed : null; } /// diff --git a/OfficeIMO.Word/README.md b/OfficeIMO.Word/README.md index 01d68744b..812226b56 100644 --- a/OfficeIMO.Word/README.md +++ b/OfficeIMO.Word/README.md @@ -285,7 +285,7 @@ var fluent = new WordFluentDocument(doc) - HTML: `OfficeIMO.Word.Html` (AngleSharp) — convert to/from HTML - Markdown: `OfficeIMO.Word.Markdown` — convert to/from Markdown using OfficeIMO.Markdown -- PDF: `OfficeIMO.Word.Pdf` (QuestPDF/SkiaSharp) — export to PDF +- PDF: `OfficeIMO.Word.Pdf` (first-party OfficeIMO.Pdf engine) — export to PDF > Note: Converters ship as adjacent packages so consumers can opt into the extra dependency surface only when needed. diff --git a/OfficeIMO.Word/WordShape.cs b/OfficeIMO.Word/WordShape.cs index 0daec567d..48a16c778 100644 --- a/OfficeIMO.Word/WordShape.cs +++ b/OfficeIMO.Word/WordShape.cs @@ -664,8 +664,19 @@ public double? StrokeWeight { if (_polygon != null) v ??= _polygon.StrokeWeight?.Value; if (_line != null) v ??= _line.StrokeWeight?.Value; if (_shape != null) v ??= _shape.StrokeWeight?.Value; - if (v is not { Length: > 0 } s) return null; - return double.Parse(s.Replace("pt", string.Empty), CultureInfo.InvariantCulture); + if (v is { Length: > 0 } s) { + return double.Parse(s.Replace("pt", string.Empty), CultureInfo.InvariantCulture); + } + + if (_wpsShape != null) { + var spPr = _wpsShape.GetFirstChild(); + var outline = spPr?.GetFirstChild(); + if (outline?.Width?.Value is int widthEmu) { + return widthEmu / (double)EmusPerPoint; + } + } + + return null; } set { string? v = value != null ? $"{value.Value.ToString(CultureInfo.InvariantCulture)}pt" : null; diff --git a/OfficeIMO.Word/WordTableCell.cs b/OfficeIMO.Word/WordTableCell.cs index 749510946..de55ef216 100644 --- a/OfficeIMO.Word/WordTableCell.cs +++ b/OfficeIMO.Word/WordTableCell.cs @@ -92,6 +92,74 @@ public bool HasVerticalMerge { } } + /// + /// Gets the number of logical columns occupied by this cell. + /// + public int ColumnSpan { + get { + int? gridSpan = _tableCellProperties?.GetFirstChild()?.Val?.Value; + if (gridSpan.HasValue && gridSpan.Value > 1) { + return gridSpan.Value; + } + + if (HorizontalMerge != MergedCellValues.Restart) { + return 1; + } + + List cells = Parent.Cells; + int columnIndex = cells.FindIndex(cell => ReferenceEquals(cell._tableCell, _tableCell)); + if (columnIndex < 0) { + return 1; + } + + int span = 1; + for (int index = columnIndex + 1; index < cells.Count; index++) { + if (cells[index].HorizontalMerge != MergedCellValues.Continue) { + break; + } + + span++; + } + + return span; + } + } + + /// + /// Gets the number of logical rows occupied by this cell. + /// + public int RowSpan { + get { + if (VerticalMerge != MergedCellValues.Restart) { + return 1; + } + + List rows = ParentTable.Rows; + int rowIndex = rows.FindIndex(row => ReferenceEquals(row._tableRow, Parent._tableRow)); + if (rowIndex < 0) { + return 1; + } + + List cells = Parent.Cells; + int columnIndex = cells.FindIndex(cell => ReferenceEquals(cell._tableCell, _tableCell)); + if (columnIndex < 0) { + return 1; + } + + int span = 1; + for (int index = rowIndex + 1; index < rows.Count; index++) { + List rowCells = rows[index].Cells; + if (columnIndex >= rowCells.Count || rowCells[columnIndex].VerticalMerge != MergedCellValues.Continue) { + break; + } + + span++; + } + + return span; + } + } + /// /// Get or set the background color of the cell using hexadecimal color code. /// diff --git a/OfficeIMO.Word/WordTableOfContent.cs b/OfficeIMO.Word/WordTableOfContent.cs index c53a4d4bd..d3f0ec198 100644 --- a/OfficeIMO.Word/WordTableOfContent.cs +++ b/OfficeIMO.Word/WordTableOfContent.cs @@ -36,6 +36,26 @@ public class WordTableOfContent : WordElement { /// public TableOfContentStyle Style { get; } + /// + /// Gets the minimum heading level included by the TOC field switch. + /// + public int MinLevel { + get { + var levels = GetConfiguredLevels(); + return levels.MinLevel; + } + } + + /// + /// Gets the maximum heading level included by the TOC field switch. + /// + public int MaxLevel { + get { + var levels = GetConfiguredLevels(); + return levels.MaxLevel; + } + } + /// /// Gets or sets the heading text displayed for the table of contents. /// @@ -296,6 +316,46 @@ private void ConfigureLevels(int minLevel, int maxLevel) { } } + private (int MinLevel, int MaxLevel) GetConfiguredLevels() { + if (_sdtBlock == null) { + return (1, 3); + } + + foreach (var simpleField in _sdtBlock.Descendants()) { + string? instruction = simpleField.Instruction?.Value ?? simpleField.Instruction; + if (TryParseLevelSwitch(instruction, out int minLevel, out int maxLevel)) { + return (minLevel, maxLevel); + } + } + + foreach (var fieldCode in _sdtBlock.Descendants()) { + if (TryParseLevelSwitch(fieldCode.Text, out int minLevel, out int maxLevel)) { + return (minLevel, maxLevel); + } + } + + return (1, 3); + } + + private static bool TryParseLevelSwitch(string? instruction, out int minLevel, out int maxLevel) { + minLevel = 1; + maxLevel = 3; + if (string.IsNullOrWhiteSpace(instruction) || + instruction!.IndexOf("TOC", StringComparison.OrdinalIgnoreCase) < 0) { + return false; + } + + Match match = Regex.Match(instruction!, @"\\o\s+(?:""(?\d+)-(?\d+)""|"(?\d+)-(?\d+)")"); + if (!match.Success || + !int.TryParse(match.Groups["min"].Value, out int parsedMin) || + !int.TryParse(match.Groups["max"].Value, out int parsedMax)) { + return false; + } + + (minLevel, maxLevel) = NormalizeLevels(parsedMin, parsedMax); + return true; + } + private static (int MinLevel, int MaxLevel) NormalizeLevels(int minLevel, int maxLevel) { var min = ClampLevel(minLevel); var max = ClampLevel(maxLevel); diff --git a/OfficeIMO.Word/WordTableRow.cs b/OfficeIMO.Word/WordTableRow.cs index 47b84d0bd..dd4893106 100644 --- a/OfficeIMO.Word/WordTableRow.cs +++ b/OfficeIMO.Word/WordTableRow.cs @@ -112,10 +112,10 @@ public bool AllowRowToBreakAcrossPages { } /// - /// Gets or sets header row at the top of each page - /// Since this is a table row property, it is not possible to set it for a single row + /// Gets or sets whether this table row should repeat at the top of each page. + /// Word repeats contiguous header rows from the start of the table. /// - internal bool RepeatHeaderRowAtTheTopOfEachPage { + public bool RepeatHeaderRowAtTheTopOfEachPage { get { if (_tableRow.TableRowProperties != null) { var rowHeader = _tableRow.TableRowProperties.OfType().FirstOrDefault(); @@ -133,10 +133,9 @@ internal bool RepeatHeaderRowAtTheTopOfEachPage { if (value == false) { rowHeader.Remove(); } - } else { + } else if (value) { // Add table header tableRowProperties.InsertAt(new TableHeader(), 0); - } } } diff --git a/OfficeIMO.sln b/OfficeIMO.sln index 2bb3c33e0..0c2b26864 100644 --- a/OfficeIMO.sln +++ b/OfficeIMO.sln @@ -202,6 +202,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OfficeIMO.Markup.Word", "Of EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OfficeIMO.Drawing", "OfficeIMO.Drawing\OfficeIMO.Drawing.csproj", "{BE05AE58-89E3-4BEE-8AC1-6C1CAAC26192}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OfficeIMO.Excel.Pdf", "OfficeIMO.Excel.Pdf\OfficeIMO.Excel.Pdf.csproj", "{574E04B3-AF70-491B-ADCA-07BB0BF599DE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -704,6 +706,18 @@ Global {BE05AE58-89E3-4BEE-8AC1-6C1CAAC26192}.Release|x64.Build.0 = Release|Any CPU {BE05AE58-89E3-4BEE-8AC1-6C1CAAC26192}.Release|x86.ActiveCfg = Release|Any CPU {BE05AE58-89E3-4BEE-8AC1-6C1CAAC26192}.Release|x86.Build.0 = Release|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Debug|x64.Build.0 = Debug|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Debug|x86.Build.0 = Debug|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Release|Any CPU.Build.0 = Release|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Release|x64.ActiveCfg = Release|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Release|x64.Build.0 = Release|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Release|x86.ActiveCfg = Release|Any CPU + {574E04B3-AF70-491B-ADCA-07BB0BF599DE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 9b696de96..851b0f4bf 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Most packages are MIT licensed. `OfficeIMO.Visio` is a special case: the project - `OfficeIMO.Word`: main Word document object model - `OfficeIMO.Word.Html`: Word to/from HTML conversion helpers - `OfficeIMO.Word.Markdown`: Word to/from Markdown conversion helpers -- `OfficeIMO.Word.Pdf`: Word to PDF export via QuestPDF and SkiaSharp +- `OfficeIMO.Word.Pdf`: Word to PDF export through the first-party `OfficeIMO.Pdf` engine ### Excel family @@ -170,7 +170,7 @@ Important exceptions: - For trimming-sensitive workloads, prefer typed overloads and explicit selectors. - `OfficeIMO.Markdown`, `OfficeIMO.CSV`, `OfficeIMO.Drawing`, `OfficeIMO.Pdf`, `OfficeIMO.Zip`, and `OfficeIMO.Epub` are the lightest dependency shapes. - Open XML-heavy packages should be tested against the exact publish options and document features your application uses. -- `OfficeIMO.Word.Pdf` should be treated separately because QuestPDF and SkiaSharp add a larger rendering/runtime surface. +- `OfficeIMO.Word.Pdf` should be treated separately because PDF layout fidelity and host fonts still need scenario validation. ## Dependencies at a glance @@ -190,8 +190,7 @@ flowchart TB OpenXml["DocumentFormat.OpenXml"] Angle["AngleSharp"] AngleCss["AngleSharp.Css"] - Quest["QuestPDF"] - Skia["SkiaSharp"] + Pdf["OfficeIMO.Pdf"] Word --> Drawing Word --> OpenXml @@ -206,8 +205,7 @@ flowchart TB WordMarkdown --> MarkdownHtml WordMarkdown --> Drawing WordPdf --> Word - WordPdf --> Quest - WordPdf --> Skia + WordPdf --> Pdf ``` ### Excel, PowerPoint, Visio, and primitives @@ -365,7 +363,7 @@ flowchart TB - `DocumentFormat.OpenXml`: `[3.5.1, 4.0.0)` in the Open XML packages that reference it - `OfficeIMO.Drawing`: first-party color and image metadata helpers - `AngleSharp` / `AngleSharp.Css`: HTML parsing and CSS conversion layers -- `QuestPDF` / `SkiaSharp`: Word-to-PDF conversion layer only +- `OfficeIMO.Pdf`: first-party Word-to-PDF conversion engine and dependency-light PDF primitives - `System.Text.Json`: reader, renderer, and Google Workspace helper surfaces on legacy target frameworks - `Microsoft.Web.WebView2`: WPF Markdown renderer host - `System.IO.Packaging`: Visio package handling diff --git a/Website/content/blog/aot-trimming-office.md b/Website/content/blog/aot-trimming-office.md index f820cf831..da7b1babf 100644 --- a/Website/content/blog/aot-trimming-office.md +++ b/Website/content/blog/aot-trimming-office.md @@ -13,7 +13,7 @@ author: "Przemyslaw Klys" - **Lower-risk starting points for AOT-sensitive workloads:** `OfficeIMO.Markdown` and `OfficeIMO.CSV` - **Test carefully with your own scenarios:** `OfficeIMO.Word`, `OfficeIMO.Excel`, `OfficeIMO.PowerPoint`, and `OfficeIMO.Reader` -- **Treat separately:** `OfficeIMO.Word.Pdf`, because it adds PDF/layout dependencies and host-font concerns +- **Treat separately:** `OfficeIMO.Word.Pdf`, because PDF layout fidelity and host-font behavior need scenario validation ## What the Repo Proves Today @@ -23,7 +23,7 @@ From the project files in this repository: - `OfficeIMO.Markdown` has no package dependencies. - `OfficeIMO.CSV` is also a lightweight package with no runtime-heavy dependency graph. - `OfficeIMO.Word`, `OfficeIMO.Excel`, `OfficeIMO.PowerPoint`, and `OfficeIMO.Reader` target modern TFMs but still sit on top of Open XML-oriented code paths. -- `OfficeIMO.Word.Pdf` brings in QuestPDF and SkiaSharp. +- `OfficeIMO.Word.Pdf` uses the first-party `OfficeIMO.Pdf` engine instead of external PDF/layout runtime packages. That means the repo supports a **strong trimming/AOT story for Markdown and CSV**, but it does **not** prove that every OfficeIMO package is uniformly NativeAOT-safe across all code paths. @@ -50,7 +50,7 @@ Typical areas to test: ### PDF conversion -`OfficeIMO.Word.Pdf` is cross-platform, but it is not as lightweight as the Markdown or CSV packages. It uses QuestPDF and SkiaSharp, so host fonts and platform packaging matter, especially in containers. +`OfficeIMO.Word.Pdf` is cross-platform and now routes through the first-party PDF engine. Host fonts and document templates still matter, especially in containers, because visual PDF fidelity depends on the available font metrics and glyph coverage. ## A Reasonable Publishing Baseline diff --git a/Website/content/blog/word-to-pdf-linux.md b/Website/content/blog/word-to-pdf-linux.md index 083008fb8..8de2bfb03 100644 --- a/Website/content/blog/word-to-pdf-linux.md +++ b/Website/content/blog/word-to-pdf-linux.md @@ -15,7 +15,7 @@ Converting Word documents to PDF is one of the most requested features in docume dotnet add package OfficeIMO.Word.Pdf ``` -The package depends on OfficeIMO.Word together with QuestPDF and SkiaSharp. You still do not need Microsoft Office or LibreOffice, but on Linux containers you should provision fonts so text measurement and output quality stay predictable. +The package depends on OfficeIMO.Word and the first-party OfficeIMO.Pdf engine. You still do not need Microsoft Office, LibreOffice, or an external PDF/layout runtime, but on Linux containers you should provision fonts so text measurement and output quality stay predictable. ## Basic Conversion diff --git a/Website/content/docs/getting-started/platform-support/index.md b/Website/content/docs/getting-started/platform-support/index.md index fd4af7eac..0ed966015 100644 --- a/Website/content/docs/getting-started/platform-support/index.md +++ b/Website/content/docs/getting-started/platform-support/index.md @@ -6,7 +6,7 @@ order: 3 # Platform Support -OfficeIMO is designed for COM-free document automation and does **not** require Microsoft Office to be installed for the workflows covered by this repo. Most packages are pure managed code; one important exception is `OfficeIMO.Word.Pdf`, which adds QuestPDF/SkiaSharp and should be tested on the target OS with the fonts you plan to ship. The framework matrix below is taken from the current project files in this repo rather than from package-marketing copy. +OfficeIMO is designed for COM-free document automation and does **not** require Microsoft Office to be installed for the workflows covered by this repo. `OfficeIMO.Word.Pdf` now uses the first-party `OfficeIMO.Pdf` engine; PDF workloads should still be tested on the target OS with the fonts and templates you plan to ship. The framework matrix below is taken from the current project files in this repo rather than from package-marketing copy. ## Target Frameworks @@ -36,7 +36,7 @@ The `.NET Framework 4.7.2` target is included for some packages only when buildi ## Native Dependencies -For the core document packages, OfficeIMO mainly relies on managed libraries such as the Open XML SDK and first-party drawing helpers. The main caveat is `OfficeIMO.Word.Pdf`, which uses QuestPDF and SkiaSharp, so runtime packaging and host fonts matter more there than they do for the rest of the suite. +For the core document packages, OfficeIMO mainly relies on managed libraries such as the Open XML SDK and first-party drawing helpers. The main PDF caveat is layout fidelity: `OfficeIMO.Word.Pdf` is dependency-light, but host fonts and document templates still affect the rendered result. ## AOT Compilation diff --git a/Website/content/docs/index.md b/Website/content/docs/index.md index bd4cb502b..d66742ace 100644 --- a/Website/content/docs/index.md +++ b/Website/content/docs/index.md @@ -54,7 +54,7 @@ The repo also includes adjacent packages such as `OfficeIMO.Word.Pdf`, `OfficeIM ## License -OfficeIMO is licensed under the [MIT License](https://github.com/EvotecIT/OfficeIMO/blob/master/LICENSE). Copyright (c) Przemyslaw Klys @ Evotec. If you need to review upstream runtime dependencies such as Open XML SDK, SixLabors.Fonts, or QuestPDF, see the [Third-Party Dependencies](/third-party/) page. +OfficeIMO is licensed under the [MIT License](https://github.com/EvotecIT/OfficeIMO/blob/master/LICENSE). Copyright (c) Przemyslaw Klys @ Evotec. If you need to review upstream runtime dependencies such as Open XML SDK or SixLabors.Fonts, see the [Third-Party Dependencies](/third-party/) page. ## Source Code diff --git a/Website/content/pages/comparison.md b/Website/content/pages/comparison.md index d3c627b19..0321b6d42 100644 --- a/Website/content/pages/comparison.md +++ b/Website/content/pages/comparison.md @@ -52,7 +52,7 @@ OfficeIMO does **not** have one uniform AOT story across every package. - `OfficeIMO.Markdown` and `OfficeIMO.CSV` are the most AOT-friendly packages in the repo. - `OfficeIMO.Word`, `OfficeIMO.Excel`, `OfficeIMO.PowerPoint`, and `OfficeIMO.Reader` depend on Open XML-based code paths and should be tested with your actual `PublishAot` or trimming scenario. -- `OfficeIMO.Word.Pdf` also adds QuestPDF/SkiaSharp and should be validated on the target OS with the fonts you plan to ship. +- `OfficeIMO.Word.Pdf` uses the first-party `OfficeIMO.Pdf` engine, but PDF output should still be validated on the target OS with the fonts and templates you plan to ship. ## Reader and Automation Differentiators diff --git a/Website/content/pages/third-party.md b/Website/content/pages/third-party.md index 5bd61edea..1f51d5bde 100644 --- a/Website/content/pages/third-party.md +++ b/Website/content/pages/third-party.md @@ -22,7 +22,7 @@ OfficeIMO packages are published under the [MIT License](https://github.com/Evot | `OfficeIMO.PowerPoint` | `DocumentFormat.OpenXml` `[3.5.1, 4.0.0)` | Presentation OOXML model and packaging | | `OfficeIMO.Word.Html` | `DocumentFormat.OpenXml` `[3.5.1, 4.0.0)`, `AngleSharp` `1.3.0`, `AngleSharp.Css` `1.0.0-beta.157` | HTML and CSS parsing for Word conversion workflows | | `OfficeIMO.Markdown.Html` | `AngleSharp` `1.3.0` | HTML parsing for Markdown conversion and bridge scenarios | -| `OfficeIMO.Word.Pdf` | `QuestPDF` `2026.2.0`, `SkiaSharp` `3.119.2` | PDF document layout and graphics rendering | +| `OfficeIMO.Word.Pdf` | First-party `OfficeIMO.Word` and `OfficeIMO.Pdf` project references | Word-to-PDF conversion through the OfficeIMO PDF engine | | `OfficeIMO.Visio` | `System.IO.Packaging` `10.0.3` | OPC packaging support for `.vsdx` files; colors and image metadata use first-party `OfficeIMO.Drawing` | | `OfficeIMO.Markdown` | No third-party runtime package references | Core package is intentionally dependency-light | | `OfficeIMO.CSV` | No third-party runtime package references | Core package is intentionally dependency-light | @@ -38,15 +38,13 @@ Additional Microsoft compatibility helpers may appear on older target frameworks | [SixLabors.Fonts 1.0.1](https://www.nuget.org/packages/SixLabors.Fonts/1.0.1) | Apache License 2.0 | Excel | OfficeIMO currently pins the `1.0.1` line. As with other Six Labors packages, review the exact package version you ship instead of assuming newer lines follow the same terms. | | [AngleSharp](https://www.nuget.org/packages/AngleSharp/1.3.0) | MIT | Markdown.Html, Word.Html | Used for HTML parsing and DOM work. | | [AngleSharp.Css](https://www.nuget.org/packages/AngleSharp.Css/1.0.0-beta.157) | MIT | Word.Html | Adds CSS parsing on top of AngleSharp for HTML conversion flows. | -| [QuestPDF 2026.2.0](https://www.nuget.org/packages/QuestPDF/2026.2.0/License) | Community MIT for qualifying cases, paid tiers otherwise | Word.Pdf | The official license guide allows MIT/community use for some cases, but larger businesses are expected to purchase a paid license. Because `OfficeIMO.Word.Pdf` references QuestPDF directly, teams should review the exact QuestPDF license guide for their own usage context. | -| [SkiaSharp](https://www.nuget.org/packages/SkiaSharp/3.119.2) | MIT | Word.Pdf | Graphics and drawing backend used by the PDF conversion layer. | | [System.IO.Packaging](https://www.nuget.org/packages/System.IO.Packaging/10.0.3) | MIT | Visio | Microsoft packaging primitives for OPC-style containers. | ## What We Recommend Teams Check 1. Review the exact `PackageReference` list for the OfficeIMO packages you ship, not just the repo root license. -2. Treat `QuestPDF` as the first dependency to review explicitly during commercial approval, and re-check exact Six Labors package versions whenever they change. -3. Re-check upstream terms whenever a dependency version changes, especially around PDF and imaging stacks. +2. Re-check exact Six Labors package versions whenever they change. +3. Re-check upstream terms whenever a dependency version changes, especially around imaging stacks. 4. Keep a copy of the upstream notices or license URLs in your own release/compliance workflow if your organization requires that. ## How We Address This In The Website From 851b23c32a85bff35f3a15546933253155482a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 31 May 2026 22:45:32 +0200 Subject: [PATCH 04/18] Address PDF logical Markdown review feedback --- .../Reading/Logical/PdfLogicalMarkdown.cs | 239 +++++++++++++++++- .../Pdf/PdfLogicalDocumentTests.cs | 59 +++++ 2 files changed, 297 insertions(+), 1 deletion(-) diff --git a/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs b/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs index d8c3a4453..d46e6462b 100644 --- a/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs +++ b/OfficeIMO.Pdf/Reading/Logical/PdfLogicalMarkdown.cs @@ -76,6 +76,10 @@ private static List BuildPageItems(PdfLogicalPage page, PdfLogical for (int i = 0; i < page.Paragraphs.Count; i++) { PdfLogicalParagraph paragraph = page.Paragraphs[i]; + if (IsParagraphRepresentedByStructuredElement(paragraph, page)) { + continue; + } + items.Add(new MarkdownItem(paragraph.YTop, paragraph.XStart, sequence++, EscapeInline(paragraph.Text))); } @@ -97,10 +101,16 @@ private static List BuildPageItems(PdfLogicalPage page, PdfLogical IReadOnlyList leaderRows = page.GetElements(PdfLogicalElementKind.LeaderRow); for (int i = 0; i < leaderRows.Count; i++) { if (leaderRows[i] is PdfLogicalLeaderRow leaderRow) { + if (IsLeaderRowRepresentedByTable(leaderRow, page.Tables)) { + continue; + } + items.Add(new MarkdownItem(null, 0, sequence++, EscapeInline(leaderRow.Label) + " | " + EscapeInline(leaderRow.Value))); } } + AppendUnmatchedTextBlocks(page, items, ref sequence); + if (options.IncludeImagePlaceholders) { for (int i = 0; i < page.Images.Count; i++) { PdfLogicalImage image = page.Images[i]; @@ -143,6 +153,146 @@ private static List BuildPageItems(PdfLogicalPage page, PdfLogical return items; } + private static void AppendUnmatchedTextBlocks(PdfLogicalPage page, List items, ref int sequence) { + for (int i = 0; i < page.TextBlocks.Count; i++) { + PdfLogicalTextBlock block = page.TextBlocks[i]; + if (IsTextBlockRepresented(block, page)) { + continue; + } + + items.Add(new MarkdownItem(block.BaselineY, block.XStart, sequence++, EscapeInline(block.Text))); + } + } + + private static bool IsTextBlockRepresented(PdfLogicalTextBlock block, PdfLogicalPage page) { + if (block.Kind == PdfLogicalElementKind.Heading || block.Kind == PdfLogicalElementKind.ListItem) { + return true; + } + + for (int i = 0; i < page.Paragraphs.Count; i++) { + PdfLogicalParagraph paragraph = page.Paragraphs[i]; + for (int lineIndex = 0; lineIndex < paragraph.Lines.Count; lineIndex++) { + if (ReferenceEquals(paragraph.Lines[lineIndex], block)) { + return true; + } + } + } + + for (int i = 0; i < page.Tables.Count; i++) { + if (IsTextBlockRepresentedByTable(block, page.Tables[i])) { + return true; + } + } + + if (IsTextBlockRepresentedByLeaderRow(block, page)) { + return true; + } + + return false; + } + + private static bool IsParagraphRepresentedByStructuredElement(PdfLogicalParagraph paragraph, PdfLogicalPage page) { + if (paragraph.Lines.Count == 0) { + return false; + } + + for (int i = 0; i < paragraph.Lines.Count; i++) { + PdfLogicalTextBlock line = paragraph.Lines[i]; + bool represented = false; + + for (int tableIndex = 0; tableIndex < page.Tables.Count; tableIndex++) { + if (IsTextBlockRepresentedByTable(line, page.Tables[tableIndex])) { + represented = true; + break; + } + } + + if (!represented && IsTextBlockRepresentedByLeaderRow(line, page)) { + represented = true; + } + + if (!represented) { + return false; + } + } + + return true; + } + + private static bool IsTextBlockRepresentedByTable(PdfLogicalTextBlock block, PdfLogicalTable table) { + double top = Math.Max(table.YTop, table.YBottom); + double bottom = Math.Min(table.YTop, table.YBottom); + if (block.BaselineY > top + 1D || block.BaselineY < bottom - 1D) { + return false; + } + + string blockText = NormalizeMarkdownComparison(block.Text); + if (blockText.Length == 0) { + return true; + } + + for (int rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++) { + string rowText = NormalizeMarkdownComparison(string.Join(" ", table.Rows[rowIndex])); + if (rowText.Length == 0) { + continue; + } + + if (ContainsOrdinal(rowText, blockText) || + ContainsOrdinal(blockText, rowText)) { + return true; + } + } + + return false; + } + + private static bool IsTextBlockRepresentedByLeaderRow(PdfLogicalTextBlock block, PdfLogicalPage page) { + IReadOnlyList leaderRows = page.GetElements(PdfLogicalElementKind.LeaderRow); + if (leaderRows.Count == 0) { + return false; + } + + string blockText = NormalizeMarkdownComparison(block.Text); + for (int i = 0; i < leaderRows.Count; i++) { + if (leaderRows[i] is not PdfLogicalLeaderRow leaderRow) { + continue; + } + + string label = NormalizeMarkdownComparison(leaderRow.Label); + string value = NormalizeMarkdownComparison(leaderRow.Value); + if (label.Length == 0 || value.Length == 0) { + continue; + } + + if (ContainsOrdinal(blockText, label) && ContainsOrdinal(blockText, value)) { + return true; + } + } + + return false; + } + + private static bool IsLeaderRowRepresentedByTable(PdfLogicalLeaderRow leaderRow, IReadOnlyList tables) { + string label = NormalizeMarkdownComparison(leaderRow.Label); + string value = NormalizeMarkdownComparison(leaderRow.Value); + for (int tableIndex = 0; tableIndex < tables.Count; tableIndex++) { + PdfLogicalTable table = tables[tableIndex]; + for (int rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++) { + IReadOnlyList row = table.Rows[rowIndex]; + if (row.Count < 2) { + continue; + } + + if (NormalizeMarkdownComparison(row[0]) == label && + NormalizeMarkdownComparison(row[row.Count - 1]) == value) { + return true; + } + } + } + + return false; + } + private static int CompareMarkdownItems(MarkdownItem left, MarkdownItem right) { bool leftHasY = left.Y.HasValue; bool rightHasY = right.Y.HasValue; @@ -258,7 +408,30 @@ private static string EscapeInline(string text) { return string.Empty; } - return text.Replace("\r", " ").Replace("\n", " ").Trim(); + string value = text.Replace("\r", " ").Replace("\n", " ").Trim(); + if (value.Length == 0) { + return string.Empty; + } + + var builder = new StringBuilder(value.Length + 8); + for (int i = 0; i < value.Length; i++) { + char ch = value[i]; + if (ch == '\\' || + ch == '`' || + ch == '*' || + ch == '_' || + ch == '[' || + ch == ']' || + ch == '<' || + ch == '>') { + builder.Append('\\'); + } + + builder.Append(ch); + } + + EscapeLinePrefix(builder); + return builder.ToString(); } private static string EscapeTableCell(string text) { @@ -269,6 +442,70 @@ private static string EscapeLinkTarget(string uri) { return uri.Replace(")", "%29"); } + private static void EscapeLinePrefix(StringBuilder builder) { + int index = 0; + while (index < builder.Length && char.IsWhiteSpace(builder[index])) { + index++; + } + + if (index >= builder.Length) { + return; + } + + char first = builder[index]; + if (first == '#' || first == '-' || first == '+' || first == '>') { + builder.Insert(index, '\\'); + return; + } + + if (!char.IsDigit(first)) { + return; + } + + int digitEnd = index + 1; + while (digitEnd < builder.Length && char.IsDigit(builder[digitEnd])) { + digitEnd++; + } + + if (digitEnd < builder.Length && (builder[digitEnd] == '.' || builder[digitEnd] == ')')) { + builder.Insert(digitEnd, '\\'); + } + } + + private static string NormalizeMarkdownComparison(string? text) { + if (string.IsNullOrWhiteSpace(text)) { + return string.Empty; + } + + var builder = new StringBuilder(text!.Length); + for (int i = 0; i < text.Length; i++) { + char ch = text[i]; + if (!char.IsWhiteSpace(ch)) { + builder.Append(char.ToUpperInvariant(ch)); + } + } + + return builder.ToString(); + } + + private static bool ContainsOrdinal(string text, string value) { + if (value.Length == 0) { + return true; + } + + if (value.Length > text.Length) { + return false; + } + + for (int i = 0; i <= text.Length - value.Length; i++) { + if (string.Compare(text, i, value, 0, value.Length, StringComparison.Ordinal) == 0) { + return true; + } + } + + return false; + } + private sealed class MarkdownItem { public MarkdownItem(double? y, double x, int sequence, string markdown) { Y = y; diff --git a/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs b/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs index 58fad7882..d29b8d574 100644 --- a/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfLogicalDocumentTests.cs @@ -161,6 +161,51 @@ public void ToMarkdown_RendersLogicalHeadingsParagraphsListsTablesAndImages() { Assert.DoesNotContain("[Image:", withoutImages, StringComparison.Ordinal); } + [Fact] + public void ToMarkdown_EscapesMarkdownControlSyntaxFromPdfText() { + byte[] pdf = PdfDoc.Create(new PdfOptions { + PageWidth = 420, + PageHeight = 260, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36, + DefaultFontSize = 10 + }) + .Paragraph(p => p.Text("# Literal heading marker")) + .Paragraph(p => p.Text("[not a link](https://example.test)")) + .ToBytes(); + + string markdown = PdfLogicalDocument.Load(pdf, new PdfTextLayoutOptions { + ForceSingleColumn = true + }).ToMarkdown(); + + string normalized = Normalize(markdown); + Assert.Contains("\\#Literalheadingmarker", normalized, StringComparison.Ordinal); + Assert.Contains("\\[notalink\\](https://example.test)", normalized, StringComparison.Ordinal); + } + + [Fact] + public void ToMarkdown_DoesNotRenderLeaderRowsTwiceWhenTableAlreadyContainsThem() { + byte[] pdf = PdfDoc.Create(new PdfOptions { + PageWidth = 420, + PageHeight = 260, + MarginLeft = 36, + MarginRight = 36, + MarginTop = 36, + MarginBottom = 36, + DefaultFontSize = 10 + }) + .Paragraph(p => p.Text("Chapter One ........ 3")) + .ToBytes(); + + string markdown = PdfLogicalDocument.Load(pdf, new PdfTextLayoutOptions { + ForceSingleColumn = true + }).ToMarkdown(); + + Assert.Equal(1, CountOccurrences(markdown, "Chapter One")); + } + [Fact] public void LoadPageRanges_BuildsLogicalModelForSelectedSourcePagesInCallerOrder() { byte[] pdf = BuildThreePageLogicalPdf(); @@ -764,6 +809,20 @@ private static bool RowContains(IReadOnlyList row, params string[] expec return expectedTokens.All(token => rowText.Contains(token, StringComparison.Ordinal)); } + private static int CountOccurrences(string text, string value) { + int count = 0; + int index = 0; + while (true) { + index = text.IndexOf(value, index, StringComparison.Ordinal); + if (index < 0) { + return count; + } + + count++; + index += value.Length; + } + } + private static void AssertContainsInOrder(string text, params string[] expectedTokens) { int lastIndex = -1; for (int i = 0; i < expectedTokens.Length; i++) { From fdd59f36b21c8d46d053bbf873f962cdca2e5b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 31 May 2026 23:21:36 +0200 Subject: [PATCH 05/18] Improve native PDF export fidelity --- .../ExcelPdfConverterExtensions.cs | 69 ++++++++-- OfficeIMO.Excel/ExcelSheet.RuleManagement.cs | 24 +++- .../Reading/Layout/TextLayoutEngine.cs | 14 +- .../Excel.ConditionalFormatting.cs | 15 +- OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 40 ++++++ OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Lists.cs | 17 +-- .../Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs | 2 +- .../Pdf/Word.SaveAsPdf.TableStyles.cs | 17 +-- OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs | 53 ++++++++ .../WordPdfConverterExtensions.Native.cs | 128 ++++++++++++++---- 10 files changed, 306 insertions(+), 73 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index 642708999..e6a51a889 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -123,8 +123,6 @@ private static IReadOnlyList BuildWorksheetExportPlans(E ExcelSheet.HeaderFooterSnapshot? headerFooter = (options.UseWorksheetHeadersAndFooters || options.UseWorksheetHeaderFooterImages) ? workbookSheet?.GetHeaderFooter() : null; string exportRange = GetExportRange(sheet, workbookSheet, options); SheetExportData exportData = ReadSheetExportData(sheet, workbookSheet, exportRange, options); - IReadOnlyList images = ReadWorksheetImages(workbookSheet, options, sheetName); - IReadOnlyList charts = ReadWorksheetCharts(workbookSheet, options, sheetName); IReadOnlyList manualRowBreaks = options.UseWorksheetPageBreaks && workbookSheet != null ? workbookSheet.GetManualRowPageBreaks() : Array.Empty(); @@ -135,10 +133,6 @@ private static IReadOnlyList BuildWorksheetExportPlans(E int rows = values.GetLength(0); int columns = values.GetLength(1); bool hasTable = rows > 0 && columns > 0; - if (!hasTable && images.Count == 0 && charts.Count == 0) { - continue; - } - int exportedRows = options.MaxRowsPerSheet.HasValue ? Math.Min(rows, options.MaxRowsPerSheet.Value) : rows; @@ -150,6 +144,13 @@ private static IReadOnlyList BuildWorksheetExportPlans(E $"Worksheet export was truncated from {rows.ToString(CultureInfo.InvariantCulture)} to {exportedRows.ToString(CultureInfo.InvariantCulture)} rows because MaxRowsPerSheet is set."); } + ISet? exportedCellReferences = CreateExportedCellReferenceSet(exportData.CellReferences, exportedRows); + IReadOnlyList images = FilterImagesByExportedCells(ReadWorksheetImages(workbookSheet, options, sheetName), exportedCellReferences); + IReadOnlyList charts = FilterChartsByExportedCells(ReadWorksheetCharts(workbookSheet, options, sheetName), exportedCellReferences); + if (!hasTable && images.Count == 0 && charts.Count == 0) { + continue; + } + plans.Add(new WorksheetPdfExportPlan( sheetName, pageSetup, @@ -2673,6 +2674,46 @@ private static IReadOnlyDictionary? CreateExportedCellReferenceSet(string?[,]? cellReferences, int exportedRows) { + if (cellReferences == null) { + return null; + } + + var exported = new HashSet(StringComparer.Ordinal); + int rows = Math.Min(exportedRows, cellReferences.GetLength(0)); + int columns = cellReferences.GetLength(1); + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + string? reference = cellReferences[row, column]; + if (!string.IsNullOrWhiteSpace(reference)) { + exported.Add(NormalizeCellReference(reference!)); + } + } + } + + return exported; + } + + private static IReadOnlyList FilterImagesByExportedCells(IReadOnlyList images, ISet? exportedCellReferences) { + if (images.Count == 0 || exportedCellReferences == null) { + return images; + } + + return images + .Where(image => exportedCellReferences.Contains(NormalizeCellReference(image.CellReference))) + .ToList(); + } + + private static IReadOnlyList FilterChartsByExportedCells(IReadOnlyList charts, ISet? exportedCellReferences) { + if (charts.Count == 0 || exportedCellReferences == null) { + return charts; + } + + return charts + .Where(chart => exportedCellReferences.Contains(A1.CellReference(chart.Snapshot.RowIndex, chart.Snapshot.ColumnIndex))) + .ToList(); + } + private static string NormalizeCellReference(string cellReference) { return cellReference.Replace("$", string.Empty).ToUpperInvariant(); } @@ -3739,7 +3780,7 @@ private static string ReplaceExcelDateTokens(string format) { } string token = format.Substring(start, i - start); - builder.Append(ConvertExcelDateToken(token, builder)); + builder.Append(ConvertExcelDateToken(token, builder, format, i)); } return builder.ToString(); @@ -3758,7 +3799,7 @@ private static bool IsExcelDateFormatLetter(char ch) { } } - private static string ConvertExcelDateToken(string token, System.Text.StringBuilder output) { + private static string ConvertExcelDateToken(string token, System.Text.StringBuilder output, string format, int nextIndex) { char lower = char.ToLowerInvariant(token[0]); switch (lower) { case 'y': @@ -3770,7 +3811,7 @@ private static string ConvertExcelDateToken(string token, System.Text.StringBuil case 's': return token.Length <= 1 ? "s" : "ss"; case 'm': - bool timeMinute = PreviousNonSpace(output) == ':'; + bool timeMinute = PreviousNonSpace(output) == ':' || NextNonSpace(format, nextIndex) == ':'; if (timeMinute) { return token.Length <= 1 ? "m" : "mm"; } @@ -3791,6 +3832,16 @@ private static char PreviousNonSpace(System.Text.StringBuilder builder) { return '\0'; } + private static char NextNonSpace(string value, int startIndex) { + for (int i = startIndex; i < value.Length; i++) { + if (!char.IsWhiteSpace(value[i])) { + return value[i]; + } + } + + return '\0'; + } + private sealed class WorksheetPdfExportPlan { public WorksheetPdfExportPlan(string sheetName, ExcelSheetPageSetup? pageSetup, ExcelSheet.HeaderFooterSnapshot? headerFooter, SheetExportData exportData, IReadOnlyList images, IReadOnlyList charts, bool hasTable, int exportedRows, IReadOnlyList manualRowBreaks, IReadOnlyList manualColumnBreaks, string bookmarkName) { SheetName = sheetName; diff --git a/OfficeIMO.Excel/ExcelSheet.RuleManagement.cs b/OfficeIMO.Excel/ExcelSheet.RuleManagement.cs index 7876cd580..f005d7c6f 100644 --- a/OfficeIMO.Excel/ExcelSheet.RuleManagement.cs +++ b/OfficeIMO.Excel/ExcelSheet.RuleManagement.cs @@ -23,7 +23,7 @@ public IReadOnlyList GetConditionalFormattingRul list.Add(new ExcelConditionalFormattingInfo { Range = range, Type = ReadConditionalFormatType(rule), - Operator = rule.Operator?.Value.ToString(), + Operator = ReadConditionalFormatOperator(rule), Priority = (int)(rule.Priority?.Value ?? 0), StopIfTrue = rule.StopIfTrue?.Value ?? false, Formulas = rule.Elements().Select(f => f.Text ?? string.Empty).ToArray(), @@ -67,6 +67,28 @@ private static string ReadConditionalFormatType(ConditionalFormattingRule rule) return rule.Type.InnerText ?? string.Empty; } + private static string? ReadConditionalFormatOperator(ConditionalFormattingRule rule) { + if (rule.Operator == null) { + return null; + } + + ConditionalFormattingOperatorValues value = rule.Operator.Value; + if (value == ConditionalFormattingOperatorValues.Between) return nameof(ConditionalFormattingOperatorValues.Between); + if (value == ConditionalFormattingOperatorValues.NotBetween) return nameof(ConditionalFormattingOperatorValues.NotBetween); + if (value == ConditionalFormattingOperatorValues.Equal) return nameof(ConditionalFormattingOperatorValues.Equal); + if (value == ConditionalFormattingOperatorValues.NotEqual) return nameof(ConditionalFormattingOperatorValues.NotEqual); + if (value == ConditionalFormattingOperatorValues.GreaterThan) return nameof(ConditionalFormattingOperatorValues.GreaterThan); + if (value == ConditionalFormattingOperatorValues.LessThan) return nameof(ConditionalFormattingOperatorValues.LessThan); + if (value == ConditionalFormattingOperatorValues.GreaterThanOrEqual) return nameof(ConditionalFormattingOperatorValues.GreaterThanOrEqual); + if (value == ConditionalFormattingOperatorValues.LessThanOrEqual) return nameof(ConditionalFormattingOperatorValues.LessThanOrEqual); + if (value == ConditionalFormattingOperatorValues.ContainsText) return nameof(ConditionalFormattingOperatorValues.ContainsText); + if (value == ConditionalFormattingOperatorValues.NotContains) return nameof(ConditionalFormattingOperatorValues.NotContains); + if (value == ConditionalFormattingOperatorValues.BeginsWith) return nameof(ConditionalFormattingOperatorValues.BeginsWith); + if (value == ConditionalFormattingOperatorValues.EndsWith) return nameof(ConditionalFormattingOperatorValues.EndsWith); + + return rule.Operator.InnerText; + } + private static IReadOnlyList ReadColorScaleColors(ConditionalFormattingRule rule) { ColorScale? colorScale = rule.GetFirstChild(); if (colorScale == null) { diff --git a/OfficeIMO.Pdf/Reading/Layout/TextLayoutEngine.cs b/OfficeIMO.Pdf/Reading/Layout/TextLayoutEngine.cs index 0f0e29718..ffdf910ae 100644 --- a/OfficeIMO.Pdf/Reading/Layout/TextLayoutEngine.cs +++ b/OfficeIMO.Pdf/Reading/Layout/TextLayoutEngine.cs @@ -267,12 +267,12 @@ private static TextLine BuildLine(List spans, Options? options) { else { // Fallback: if both look like full words and there is a visible gap, insert a space bool bothAlphaLong = AllWordish(prev.Text) && AllWordish(s.Text) && prev.Text.Length >= 2 && s.Text.Length >= 2; - if (bothAlphaLong && gap > 0.8 && (text.Length > 0 && text[text.Length - 1] != ' ')) text.Append(' '); + if ((bothAlphaLong || ShouldRespectVisibleGap(prev.Text, s.Text)) && IsVisibleWordGap(gap, s.FontSize) && (text.Length > 0 && text[text.Length - 1] != ' ')) text.Append(' '); } } else if (!isLeader) { // Guard: if both chunks look like full words (>=2 letters) and there is any visible gap, emit a space bool bothAlphaLong = AllWordish(prev.Text) && AllWordish(s.Text) && prev.Text.Length >= 2 && s.Text.Length >= 2; - if (bothAlphaLong && gap > 0.8 && (text.Length > 0 && text[text.Length - 1] != ' ')) { + if ((bothAlphaLong || ShouldRespectVisibleGap(prev.Text, s.Text)) && IsVisibleWordGap(gap, s.FontSize) && (text.Length > 0 && text[text.Length - 1] != ' ')) { text.Append(' '); } if (gap > threshold) text.Append(' '); @@ -359,6 +359,16 @@ private static bool ContainsDigit(string s) { private static bool IsWordish(char c) => char.IsLetter(c) || c == '\'' || c == '-' || c == '/'; private static bool AllWordish(string s) { if (string.IsNullOrEmpty(s)) return false; for (int i = 0; i < s.Length; i++) if (!IsWordish(s[i])) return false; return true; } private static bool IsShortAbbrev(string s) { if (string.IsNullOrEmpty(s) || s.Length > 3) return false; for (int i = 0; i < s.Length; i++) if (!char.IsUpper(s[i])) return false; return true; } + private static bool IsVisibleWordGap(double gap, double fontSize) => + gap > System.Math.Max(0.8, System.Math.Min(2.0, fontSize * 0.18)); + private static bool ShouldRespectVisibleGap(string left, string right) { + if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right)) return false; + char a = left[left.Length - 1]; + char b = right[0]; + bool leftBoundary = char.IsLetterOrDigit(a) || a == ':' || a == ';' || a == ',' || a == '.' || a == ')' || a == '"' || a == '\''; + bool rightBoundary = char.IsLetterOrDigit(b) || b == '(' || b == '"' || b == '\''; + return leftBoundary && rightBoundary; + } private static string NormalizeLineText(string s) { if (string.IsNullOrEmpty(s)) return s; s = System.Text.RegularExpressions.Regex.Replace(s, "\\s+", " ").Trim(); diff --git a/OfficeIMO.Tests/Excel.ConditionalFormatting.cs b/OfficeIMO.Tests/Excel.ConditionalFormatting.cs index a27b1165e..c72302046 100644 --- a/OfficeIMO.Tests/Excel.ConditionalFormatting.cs +++ b/OfficeIMO.Tests/Excel.ConditionalFormatting.cs @@ -34,8 +34,9 @@ public void Test_AddConditionalRule() { using (var document = ExcelDocument.Load(filePath, readOnly: true)) { ExcelConditionalFormattingInfo info = Assert.Single(document.Sheets[0].GetConditionalFormattingRules("A1:A3")); - Assert.Equal("DataBar", info.Type); - Assert.Equal("FF0000FF", info.DataBarColor); + Assert.Equal("CellIs", info.Type); + Assert.Equal(nameof(ConditionalFormattingOperatorValues.GreaterThan), info.Operator); + Assert.Equal(new[] { "10" }, info.Formulas); Assert.Empty(document.ValidateOpenXml()); } } @@ -69,9 +70,13 @@ public void Test_RangeFluentConditionalFormatting() { using (var document = ExcelDocument.Load(filePath, readOnly: true)) { var sheet = document.Sheets[0]; - ExcelConditionalFormattingInfo info = Assert.Single(sheet.GetConditionalFormattingRules("A1:A3")); - Assert.Equal("ColorScale", info.Type); - Assert.Equal(new[] { "FFFF0000", "FF00FF00" }, info.ColorScaleColors); + var rules = sheet.GetConditionalFormattingRules("A1:A3"); + ExcelConditionalFormattingInfo colorScale = Assert.Single(rules, info => info.Type == "ColorScale"); + ExcelConditionalFormattingInfo dataBar = Assert.Single(rules, info => info.Type == "DataBar"); + ExcelConditionalFormattingInfo top = Assert.Single(rules, info => info.Type == "Top10"); + Assert.Equal(new[] { "FFFF0000", "FF00FF00" }, colorScale.ColorScaleColors); + Assert.Equal("FF0000FF", dataBar.DataBarColor); + Assert.True(top.Priority > 0); Assert.Empty(document.ValidateOpenXml()); } } diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index c1ae65b06..c0ac6d47c 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -233,6 +233,42 @@ public void SaveAsPdf_ExcelWorkbook_Uses_Worksheet_Print_Area() { Assert.DoesNotContain("OutsideRight", text); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Filters_Images_And_Charts_Outside_Print_Area() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfPrintAreaMedia.xlsx"); + byte[] imageBytes = CreateMinimalRgbPng(); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Report")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(2, 2, "Category"); + sheet.Cell(2, 3, "Value"); + sheet.Cell(3, 2, "Inside"); + sheet.Cell(3, 3, 10); + sheet.Cell(10, 1, "OutsideData"); + sheet.AddImage(3, 2, imageBytes, "image/png", widthPixels: 12, heightPixels: 12, name: "Inside image"); + sheet.AddImage(10, 1, imageBytes, "image/png", widthPixels: 12, heightPixels: 12, name: "Outside image"); + sheet.AddChartFromRange("B2:C3", row: 3, column: 2, widthPixels: 220, heightPixels: 120, type: ExcelChartType.ColumnClustered, title: "Inside Chart"); + sheet.AddChartFromRange("B2:C3", row: 10, column: 1, widthPixels: 220, heightPixels: 120, type: ExcelChartType.ColumnClustered, title: "Outside Chart"); + document.SetPrintArea(sheet, "B2:C3"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + UseWorksheetPrintAreas = true, + PageSize = new PdfCore.PageSize(420, 320), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Inside Chart", text); + Assert.DoesNotContain("Outside Chart", text); + Assert.DoesNotContain("OutsideData", text); + Assert.Single(PdfCore.PdfImageExtractor.ExtractImages(bytes)); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Uses_Worksheet_Orientation_And_Margins() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfWorksheetPageSetup.xlsx"); @@ -1068,6 +1104,8 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { sheet.CellAt(3, 2).SetValue(0.257).Percent(1); sheet.Cell(4, 1, "Date"); sheet.CellAt(4, 2).SetValue(new DateTime(2026, 1, 15)).Date("yyyy-mm-dd"); + sheet.Cell(5, 1, "Minutes"); + sheet.CellAt(5, 2).SetValue(new DateTime(2026, 1, 15, 0, 30, 5)).SetNumberFormat("mm:ss"); ExcelCellStyleSnapshot currencyStyle = sheet.CellAt(2, 2).GetStyle(); Assert.Equal("\"$\"#,##0.00", currencyStyle.NumberFormatCode); @@ -1092,6 +1130,8 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { Assert.Contains("$1,234.50", text); Assert.Contains("25.7%", text); Assert.Contains("2026-01-15", text); + Assert.Contains("30:05", text); + Assert.DoesNotContain("01:05", text); Assert.DoesNotContain("1234.5", text); Assert.DoesNotContain("0.257", text); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Lists.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Lists.cs index ce76e5d3b..1ff10897b 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Lists.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Lists.cs @@ -1,7 +1,6 @@ using OfficeIMO.Word.Pdf; using OfficeIMO.Word; using System.IO; -using System.IO.Compression; using System.Text; using Xunit; @@ -33,21 +32,7 @@ public void Test_WordDocument_SaveAsPdf_MultiLevelLists() { Assert.True(File.Exists(pdfPath)); byte[] bytes = File.ReadAllBytes(pdfPath); - byte[] startPattern = Encoding.ASCII.GetBytes("stream\n"); - byte[] endPattern = Encoding.ASCII.GetBytes("\nendstream"); - int start = IndexOf(bytes, startPattern, 0); - Assert.True(start >= 0, "stream marker not found"); - start += startPattern.Length; - int end = IndexOf(bytes, endPattern, start); - Assert.True(end >= 0, "endstream marker not found"); - int length = end - start; - int deflateLength = length - 6; - byte[] deflateData = new byte[deflateLength]; - Array.Copy(bytes, start + 2, deflateData, 0, deflateLength); - using MemoryStream ms = new MemoryStream(deflateData); - using DeflateStream ds = new DeflateStream(ms, CompressionMode.Decompress); - using StreamReader reader = new StreamReader(ds, Encoding.GetEncoding("ISO-8859-1")); - string content = reader.ReadToEnd(); + string content = ReadFirstPdfStreamContent(bytes); Assert.False(string.IsNullOrEmpty(content)); } } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs index b656d734f..e9e45ef4c 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs @@ -1076,7 +1076,7 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Cell_Rich_Runs() { Assert.True(markedText > boldText, "Expected encoded 'CellMarked' text after the bold table cell run."); Assert.True(largeText > markedText, "Expected encoded 'CellLarge' text after the highlighted table cell run."); Assert.True(content.LastIndexOf("1 0 0 rg", redText, StringComparison.Ordinal) >= 0, "Expected Word table cell run color to emit a red PDF fill color."); - Assert.True(content.LastIndexOf("/F2 11 Tf", boldText, StringComparison.Ordinal) >= 0, "Expected Word table cell bold run to use the bold PDF font resource."); + Assert.True(content.LastIndexOf("/F2 ", boldText, StringComparison.Ordinal) >= 0, "Expected Word table cell bold run to use the bold PDF font resource."); Assert.True(content.LastIndexOf("1 1 0 rg", markedText, StringComparison.Ordinal) >= 0, "Expected Word table cell run highlight to emit a yellow PDF fill color."); Assert.True(content.LastIndexOf(" 18 Tf", largeText, StringComparison.Ordinal) >= 0, "Expected Word table cell run font size to emit an 18-point PDF run."); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs index 26791227b..ea10af133 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs @@ -3,7 +3,6 @@ using OfficeIMO.Word.Pdf; using OfficeIMO.Word; using System.IO; -using System.IO.Compression; using System.Linq; using System.Reflection; using System.Text; @@ -41,21 +40,7 @@ public void Test_WordDocument_SaveAsPdf_TableStyles() { } byte[] bytes = File.ReadAllBytes(pdfPath); - byte[] startPattern = Encoding.ASCII.GetBytes("stream\n"); - byte[] endPattern = Encoding.ASCII.GetBytes("\nendstream"); - int start = IndexOf(bytes, startPattern, 0); - Assert.True(start >= 0, "stream marker not found"); - start += startPattern.Length; - int end = IndexOf(bytes, endPattern, start); - Assert.True(end >= 0, "endstream marker not found"); - int length = end - start; - int deflateLength = length - 6; - byte[] deflateData = new byte[deflateLength]; - Array.Copy(bytes, start + 2, deflateData, 0, deflateLength); - using MemoryStream ms = new MemoryStream(deflateData); - using DeflateStream ds = new DeflateStream(ms, CompressionMode.Decompress); - using StreamReader reader = new StreamReader(ds, Encoding.GetEncoding("ISO-8859-1")); - string content = reader.ReadToEnd(); + string content = ReadFirstPdfStreamContent(bytes); Assert.Contains("1 0 0 rg", content); Assert.Contains("0 0 1 RG", content); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs index b5263de80..2541f3ad5 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs @@ -461,6 +461,59 @@ private static int IndexOf(byte[] buffer, byte[] pattern, int start) { return -1; } + private static string ReadFirstPdfStreamContent(byte[] bytes) { + int start = FindPdfStreamDataStart(bytes); + Assert.True(start >= 0, "stream marker not found"); + + byte[] endPattern = Encoding.ASCII.GetBytes("endstream"); + int end = IndexOf(bytes, endPattern, start); + Assert.True(end >= 0, "endstream marker not found"); + + int length = end - start; + while (length > 0 && (bytes[start + length - 1] == '\r' || bytes[start + length - 1] == '\n')) { + length--; + } + + byte[] streamData = new byte[length]; + Array.Copy(bytes, start, streamData, 0, length); + + if (TryInflatePdfStream(streamData, 0, streamData.Length, out string inflated)) { + return inflated; + } + + if (streamData.Length > 6 && + TryInflatePdfStream(streamData, 2, streamData.Length - 6, out inflated)) { + return inflated; + } + + return Encoding.GetEncoding("ISO-8859-1").GetString(streamData); + } + + private static int FindPdfStreamDataStart(byte[] bytes) { + byte[] lfPattern = Encoding.ASCII.GetBytes("stream\n"); + int lfStart = IndexOf(bytes, lfPattern, 0); + if (lfStart >= 0) { + return lfStart + lfPattern.Length; + } + + byte[] crlfPattern = Encoding.ASCII.GetBytes("stream\r\n"); + int crlfStart = IndexOf(bytes, crlfPattern, 0); + return crlfStart >= 0 ? crlfStart + crlfPattern.Length : -1; + } + + private static bool TryInflatePdfStream(byte[] bytes, int offset, int count, out string content) { + try { + using var source = new MemoryStream(bytes, offset, count); + using var deflate = new System.IO.Compression.DeflateStream(source, System.IO.Compression.CompressionMode.Decompress); + using var reader = new StreamReader(deflate, Encoding.GetEncoding("ISO-8859-1")); + content = reader.ReadToEnd(); + return !string.IsNullOrEmpty(content); + } catch (InvalidDataException) { + content = string.Empty; + return false; + } + } + private static void AssertPdfUsesFont(string pdfPath, string expectedFontNamePart) { using PdfDocument pdf = PdfDocument.Open(pdfPath); Assert.Contains(pdf.GetPage(1).Letters, letter => diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs index edcc2afc4..4c10dbbc0 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -2474,20 +2474,6 @@ private static IReadOnlyList BuildNativeTableOfConte consumedOnPage = 0D; } - if (element is WordParagraph headingParagraph) { - int headingLevel = GetNativeTableOfContentsHeadingLevel(headingParagraph); - if (headingLevel > 0) { - string headingText = GetNativeParagraphDisplayText(headingParagraph); - if (!string.IsNullOrWhiteSpace(headingText)) { - string? destinationName = headingParagraph._paragraph != null && - headingDestinations.TryGetValue(headingParagraph._paragraph, out string? foundDestination) - ? foundDestination - : null; - entries.Add(new NativeTableOfContentsEntry(headingText, headingLevel, currentPage, destinationName)); - } - } - } - if (element is WordParagraph pageBreakParagraph && pageBreakParagraph.IsPageBreak) { currentPage++; consumedOnPage = 0D; @@ -2510,6 +2496,20 @@ private static IReadOnlyList BuildNativeTableOfConte consumedOnPage = 0D; } + if (element is WordParagraph headingParagraph) { + int headingLevel = GetNativeTableOfContentsHeadingLevel(headingParagraph); + if (headingLevel > 0) { + string headingText = GetNativeParagraphDisplayText(headingParagraph); + if (!string.IsNullOrWhiteSpace(headingText)) { + string? destinationName = headingParagraph._paragraph != null && + headingDestinations.TryGetValue(headingParagraph._paragraph, out string? foundDestination) + ? foundDestination + : null; + entries.Add(new NativeTableOfContentsEntry(headingText, headingLevel, currentPage, destinationName)); + } + } + } + consumedOnPage += estimatedHeight; while (consumedOnPage > contentHeight) { currentPage++; @@ -2708,7 +2708,7 @@ private static void RenderNativeElement(INativePdfFlow pdf, WordElement element, RenderNativeTable(pdf, table, getMarker, footnoteNumbersById, options); break; case WordImage image: - RenderNativeImage(pdf, image); + RenderNativeImage(pdf, image, options: options, source: "body image"); break; case WordHyperLink link: RenderNativeHyperLink(pdf, link); @@ -2782,12 +2782,12 @@ private static void RenderNativeParagraph(INativePdfFlow pdf, WordParagraph para } if (paragraph.Image != null) { - RenderNativeImage(pdf, paragraph.Image, MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false)); + RenderNativeImage(pdf, paragraph.Image, MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false), options, "body paragraph image"); } WordImage? pictureControlImage = paragraph.PictureControl?.Image; if (pictureControlImage != null) { - RenderNativeImage(pdf, pictureControlImage, MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false)); + RenderNativeImage(pdf, pictureControlImage, MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false), options, "body picture control image"); } List runs = GetNativeRuns(paragraph); @@ -3020,6 +3020,10 @@ private static IReadOnlyList GetNativeRepeatingSectionItems(W.SdtRun rep private static IReadOnlyList CreateNativeTableCellImages(WordTableCell cell) { var images = new List(); foreach (WordParagraph paragraph in GetNativeCellParagraphs(cell)) { + if (paragraph.Image != null) { + AddNativeTableCellImage(images, paragraph.Image); + } + foreach (W.SdtRun pictureControl in GetNativePictureControls(paragraph)) { var pictureParagraph = new WordParagraph(paragraph._document, paragraph._paragraph!, pictureControl); WordImage? pictureControlImage = pictureParagraph.PictureControl?.Image; @@ -3027,16 +3031,24 @@ private static IReadOnlyList GetNativeRepeatingSectionItems(W.SdtRun rep continue; } - byte[] bytes = ImageEmbedder.GetImageBytes(pictureControlImage); - double width = pictureControlImage.Width.HasValue ? pictureControlImage.Width.Value * 72D / 96D : 144D; - double height = pictureControlImage.Height.HasValue ? pictureControlImage.Height.Value * 72D / 96D : 144D; - images.Add(new PdfCore.PdfTableCellImage(bytes, width, height)); + AddNativeTableCellImage(images, pictureControlImage); } } return images; } + private static void AddNativeTableCellImage(List images, WordImage image) { + byte[] bytes = ImageEmbedder.GetImageBytes(image); + if (!IsNativePdfSupportedImageBytes(bytes, out _)) { + return; + } + + double width = image.Width.HasValue ? image.Width.Value * 72D / 96D : 144D; + double height = image.Height.HasValue ? image.Height.Value * 72D / 96D : 144D; + images.Add(new PdfCore.PdfTableCellImage(bytes, width, height)); + } + private static void AddNativeParagraphContent( PdfCore.PdfParagraphBuilder builder, WordParagraph paragraph, @@ -4721,7 +4733,7 @@ private static void AddNativeCellTextRuns(List target, string t int currentTabIndex = tabIndex; AddNativeTextSegments( text, - value => target.Add(createRun(value)), + value => AddOrMergeNativeCellTextRun(target, createRun(value)), () => target.Add(PdfCore.TextRun.LineBreak()), () => { target.Add(CreateNativeCellTabRun(tabStops, currentTabIndex)); @@ -4731,6 +4743,49 @@ private static void AddNativeCellTextRuns(List target, string t tabIndex = currentTabIndex; } + private static void AddOrMergeNativeCellTextRun(List target, PdfCore.TextRun run) { + if (target.Count == 0 || !CanMergeNativeCellTextRuns(target[target.Count - 1], run)) { + target.Add(run); + return; + } + + PdfCore.TextRun previous = target[target.Count - 1]; + target[target.Count - 1] = new PdfCore.TextRun( + previous.Text + run.Text, + bold: previous.Bold, + underline: previous.Underline, + color: previous.Color, + italic: previous.Italic, + strike: previous.Strike, + fontSize: previous.FontSize, + font: previous.Font, + baseline: previous.Baseline, + backgroundColor: previous.BackgroundColor); + } + + private static bool CanMergeNativeCellTextRuns(PdfCore.TextRun left, PdfCore.TextRun right) => + left.LinkUri == null && + left.LinkDestinationName == null && + right.LinkUri == null && + right.LinkDestinationName == null && + left.TabLeader == PdfCore.PdfTabLeaderStyle.None && + right.TabLeader == PdfCore.PdfTabLeaderStyle.None && + left.TabAlignment == PdfCore.PdfTabAlignment.Left && + right.TabAlignment == PdfCore.PdfTabAlignment.Left && + left.Text != "\n" && + left.Text != "\t" && + right.Text != "\n" && + right.Text != "\t" && + left.Bold == right.Bold && + left.Underline == right.Underline && + left.Italic == right.Italic && + left.Strike == right.Strike && + NullableDoubleEquals(left.FontSize, right.FontSize) && + left.Font == right.Font && + left.Baseline == right.Baseline && + Equals(left.Color, right.Color) && + Equals(left.BackgroundColor, right.BackgroundColor); + private static PdfCore.TextRun CreateNativeCellTextRun(string text, WordParagraph paragraph) => new PdfCore.TextRun( text, @@ -5024,17 +5079,44 @@ private static void RenderNativeFootnotes(INativePdfFlow pdf, IReadOnlyList Date: Mon, 1 Jun 2026 09:11:47 +0200 Subject: [PATCH 06/18] Fix PDF roadmap PR feedback --- .../ExcelPdfConverterExtensions.cs | 130 ++++++++++-------- .../Rendering/Writer/PdfWriter.Layout.cs | 32 ++--- ...ceimo-pdf-professional-report.snapshot.txt | 31 +++-- ...imo-pdf-representative-report.snapshot.txt | 22 ++- OfficeIMO.Word.Pdf/TableLayoutCache.cs | 11 +- Website/.powerforge/seo-baseline.json | 36 ++++- 6 files changed, 170 insertions(+), 92 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index e6a51a889..bfdd04eb4 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -145,8 +145,9 @@ private static IReadOnlyList BuildWorksheetExportPlans(E } ISet? exportedCellReferences = CreateExportedCellReferenceSet(exportData.CellReferences, exportedRows); - IReadOnlyList images = FilterImagesByExportedCells(ReadWorksheetImages(workbookSheet, options, sheetName), exportedCellReferences); - IReadOnlyList charts = FilterChartsByExportedCells(ReadWorksheetCharts(workbookSheet, options, sheetName), exportedCellReferences); + bool filterMediaToExportedCells = HasWorksheetPrintArea(workbookSheet, options) || options.MaxRowsPerSheet.HasValue; + IReadOnlyList images = FilterImagesByExportedCells(ReadWorksheetImages(workbookSheet, options, sheetName), exportedCellReferences, filterMediaToExportedCells); + IReadOnlyList charts = FilterChartsByExportedCells(ReadWorksheetCharts(workbookSheet, options, sheetName), exportedCellReferences, filterMediaToExportedCells); if (!hasTable && images.Count == 0 && charts.Count == 0) { continue; } @@ -466,23 +467,23 @@ private static HeaderFooterZone ConvertHeaderFooterText(string? text, string she hasVisibleContent = true; break; case 'A': - builder.Append(sheetName); + builder.Append(NormalizeHeaderFooterFieldText(sheetName)); hasVisibleContent = true; break; case 'D': - builder.Append(GetHeaderFooterDateTime(options, ref headerFooterDateTime).ToString("d", CultureInfo.CurrentCulture)); + builder.Append(NormalizeHeaderFooterFieldText(GetHeaderFooterDateTime(options, ref headerFooterDateTime).ToString("d", CultureInfo.CurrentCulture))); hasVisibleContent = true; break; case 'T': - builder.Append(GetHeaderFooterDateTime(options, ref headerFooterDateTime).ToString("t", CultureInfo.CurrentCulture)); + builder.Append(NormalizeHeaderFooterFieldText(GetHeaderFooterDateTime(options, ref headerFooterDateTime).ToString("t", CultureInfo.CurrentCulture))); hasVisibleContent = true; break; case 'F': - builder.Append(GetHeaderFooterFileName(workbookPath)); + builder.Append(NormalizeHeaderFooterFieldText(GetHeaderFooterFileName(workbookPath))); hasVisibleContent = true; break; case 'Z': - builder.Append(GetHeaderFooterDirectory(workbookPath)); + builder.Append(NormalizeHeaderFooterFieldText(GetHeaderFooterDirectory(workbookPath))); hasVisibleContent = true; break; case 'G': @@ -702,6 +703,9 @@ private static string GetHeaderFooterDirectory(string? workbookPath) { return directory ?? string.Empty; } + private static string NormalizeHeaderFooterFieldText(string text) => + text.Replace('\u00A0', ' ').Replace('\u202F', ' '); + private static bool TryReadHeaderFooterFontSize(string text, int startIndex, out double fontSize, out int endIndex) { fontSize = 0D; endIndex = startIndex; @@ -847,9 +851,9 @@ private static bool IsHexDigit(char value) { private static void ApplyWorksheetPageSetup(PdfCore.PdfPageCompose page, ExcelSheetPageSetup? pageSetup, ExcelPdfSaveOptions options) { PdfCore.PageSize? pageSize = options.PageSize; - if (pageSetup?.Orientation == ExcelPageOrientation.Landscape) { + if (!options.PageSize.HasValue && pageSetup?.Orientation == ExcelPageOrientation.Landscape) { pageSize = (pageSize ?? PdfCore.PageSizes.Letter).Landscape(); - } else if (pageSetup?.Orientation == ExcelPageOrientation.Portrait) { + } else if (!options.PageSize.HasValue && pageSetup?.Orientation == ExcelPageOrientation.Portrait) { pageSize = (pageSize ?? PdfCore.PageSizes.Letter).Portrait(); } @@ -1769,16 +1773,20 @@ private static bool IsDoughnutChart(ExcelChartType type) { } private static string GetExportRange(ExcelSheetReader sheet, ExcelSheet? workbookSheet, ExcelPdfSaveOptions options) { - if (options.UseWorksheetPrintAreas && workbookSheet != null) { - string? printArea = workbookSheet.GetPrintArea(); - if (!string.IsNullOrWhiteSpace(printArea)) { - return NormalizeA1Range(printArea!); - } + string? printArea = GetWorksheetPrintArea(workbookSheet, options); + if (!string.IsNullOrWhiteSpace(printArea)) { + return NormalizeA1Range(printArea!); } return sheet.GetUsedRangeA1(); } + private static bool HasWorksheetPrintArea(ExcelSheet? workbookSheet, ExcelPdfSaveOptions options) => + !string.IsNullOrWhiteSpace(GetWorksheetPrintArea(workbookSheet, options)); + + private static string? GetWorksheetPrintArea(ExcelSheet? workbookSheet, ExcelPdfSaveOptions options) => + options.UseWorksheetPrintAreas && workbookSheet != null ? workbookSheet.GetPrintArea() : null; + private static SheetExportData ReadSheetExportData(ExcelSheetReader sheet, ExcelSheet? workbookSheet, string exportRange, ExcelPdfSaveOptions options) { string normalizedRange = NormalizeA1Range(exportRange); RangeExportData bodyRange = ReadRangeExportData(sheet, workbookSheet, normalizedRange, options); @@ -1801,15 +1809,22 @@ private static SheetExportData ReadSheetExportData(ExcelSheetReader sheet, Excel int firstTitleRow = titles.FirstRow!.Value; int lastTitleRow = titles.LastRow!.Value; - if (lastTitleRow < rangeFirstRow) { - string titleRange = ToA1Range(firstTitleRow, rangeFirstColumn, lastTitleRow, rangeLastColumn); + if (firstTitleRow < rangeFirstRow) { + int prependedLastTitleRow = Math.Min(lastTitleRow, rangeFirstRow - 1); + string titleRange = ToA1Range(firstTitleRow, rangeFirstColumn, prependedLastTitleRow, rangeLastColumn); RangeExportData titleRangeData = ReadRangeExportData(sheet, workbookSheet, titleRange, options); + int prependedRowCount = titleRangeData.Values.GetLength(0); + int bodyRowCount = values.GetLength(0); + int columnCount = values.GetLength(1); object?[,] prependedValues = PrependRows(titleRangeData.Values, values); - ExcelCellStyleSnapshot?[,]? prependedStyles = PrependRows(titleRangeData.Styles, styles); - ExcelHyperlinkSnapshot?[,]? prependedHyperlinks = PrependRows(titleRangeData.Hyperlinks, hyperlinks); - string?[,]? prependedCellReferences = PrependRows(titleRangeData.CellReferences, cellReferences); - MergeLayoutData? prependedMergedCells = PrependRows(titleRangeData.MergedCells, mergedCells, titleRangeData.Values.GetLength(0), values.GetLength(0), values.GetLength(1)); - RowLayoutData? prependedRowHeights = PrependRows(titleRangeData.RowHeights, rowHeights, titleRangeData.Values.GetLength(0), values.GetLength(0)); + ExcelCellStyleSnapshot?[,]? prependedStyles = PrependRows(titleRangeData.Styles, styles, prependedRowCount, bodyRowCount, columnCount); + ExcelHyperlinkSnapshot?[,]? prependedHyperlinks = PrependRows(titleRangeData.Hyperlinks, hyperlinks, prependedRowCount, bodyRowCount, columnCount); + string?[,]? prependedCellReferences = PrependRows(titleRangeData.CellReferences, cellReferences, prependedRowCount, bodyRowCount, columnCount); + MergeLayoutData? prependedMergedCells = PrependRows(titleRangeData.MergedCells, mergedCells, prependedRowCount, bodyRowCount, columnCount); + RowLayoutData? prependedRowHeights = PrependRows(titleRangeData.RowHeights, rowHeights, prependedRowCount, bodyRowCount); + int overlappingTitleRows = lastTitleRow >= rangeFirstRow + ? Math.Min(bodyRowCount, lastTitleRow - rangeFirstRow + 1) + : 0; return CreateSheetExportData( workbookSheet, prependedValues, @@ -1819,7 +1834,7 @@ private static SheetExportData ReadSheetExportData(ExcelSheetReader sheet, Excel prependedMergedCells, columnWidths, prependedRowHeights, - Math.Max(headerRows, titleRangeData.Values.GetLength(0)), + Math.Max(headerRows, prependedRowCount + overlappingTitleRows), options); } @@ -2480,57 +2495,54 @@ private static List MapVisibleOffsets(int firstSourceOffset, int lastSource return result; } - private static ExcelCellStyleSnapshot?[,]? PrependRows(ExcelCellStyleSnapshot?[,]? topRows, ExcelCellStyleSnapshot?[,]? bodyRows) { - if (topRows == null) { - return bodyRows; + private static ExcelCellStyleSnapshot?[,]? PrependRows(ExcelCellStyleSnapshot?[,]? topRows, ExcelCellStyleSnapshot?[,]? bodyRows, int topRowCount, int bodyRowCount, int columnCount) { + if (topRows == null && bodyRows == null) { + return null; } - if (bodyRows == null) { - return topRows; + var result = new ExcelCellStyleSnapshot?[topRowCount + bodyRowCount, columnCount]; + if (topRows != null) { + CopyRows(topRows, result, 0, columnCount); + } + + if (bodyRows != null) { + CopyRows(bodyRows, result, topRowCount, columnCount); } - int topRowCount = topRows.GetLength(0); - int bodyRowCount = bodyRows.GetLength(0); - int columnCount = bodyRows.GetLength(1); - var result = new ExcelCellStyleSnapshot?[topRowCount + bodyRowCount, columnCount]; - CopyRows(topRows, result, 0, columnCount); - CopyRows(bodyRows, result, topRowCount, columnCount); return result; } - private static ExcelHyperlinkSnapshot?[,]? PrependRows(ExcelHyperlinkSnapshot?[,]? topRows, ExcelHyperlinkSnapshot?[,]? bodyRows) { - if (topRows == null) { - return bodyRows; + private static ExcelHyperlinkSnapshot?[,]? PrependRows(ExcelHyperlinkSnapshot?[,]? topRows, ExcelHyperlinkSnapshot?[,]? bodyRows, int topRowCount, int bodyRowCount, int columnCount) { + if (topRows == null && bodyRows == null) { + return null; + } + + var result = new ExcelHyperlinkSnapshot?[topRowCount + bodyRowCount, columnCount]; + if (topRows != null) { + CopyRows(topRows, result, 0, columnCount); } - if (bodyRows == null) { - return topRows; + if (bodyRows != null) { + CopyRows(bodyRows, result, topRowCount, columnCount); } - int topRowCount = topRows.GetLength(0); - int bodyRowCount = bodyRows.GetLength(0); - int columnCount = bodyRows.GetLength(1); - var result = new ExcelHyperlinkSnapshot?[topRowCount + bodyRowCount, columnCount]; - CopyRows(topRows, result, 0, columnCount); - CopyRows(bodyRows, result, topRowCount, columnCount); return result; } - private static string?[,]? PrependRows(string?[,]? topRows, string?[,]? bodyRows) { - if (topRows == null) { - return bodyRows; + private static string?[,]? PrependRows(string?[,]? topRows, string?[,]? bodyRows, int topRowCount, int bodyRowCount, int columnCount) { + if (topRows == null && bodyRows == null) { + return null; + } + + var result = new string?[topRowCount + bodyRowCount, columnCount]; + if (topRows != null) { + CopyRows(topRows, result, 0, columnCount); } - if (bodyRows == null) { - return topRows; + if (bodyRows != null) { + CopyRows(bodyRows, result, topRowCount, columnCount); } - int topRowCount = topRows.GetLength(0); - int bodyRowCount = bodyRows.GetLength(0); - int columnCount = bodyRows.GetLength(1); - var result = new string?[topRowCount + bodyRowCount, columnCount]; - CopyRows(topRows, result, 0, columnCount); - CopyRows(bodyRows, result, topRowCount, columnCount); return result; } @@ -2694,8 +2706,8 @@ private static IReadOnlyDictionary FilterImagesByExportedCells(IReadOnlyList images, ISet? exportedCellReferences) { - if (images.Count == 0 || exportedCellReferences == null) { + private static IReadOnlyList FilterImagesByExportedCells(IReadOnlyList images, ISet? exportedCellReferences, bool enabled) { + if (!enabled || images.Count == 0 || exportedCellReferences == null) { return images; } @@ -2704,8 +2716,8 @@ private static IReadOnlyList FilterImagesByExportedCel .ToList(); } - private static IReadOnlyList FilterChartsByExportedCells(IReadOnlyList charts, ISet? exportedCellReferences) { - if (charts.Count == 0 || exportedCellReferences == null) { + private static IReadOnlyList FilterChartsByExportedCells(IReadOnlyList charts, ISet? exportedCellReferences, bool enabled) { + if (!enabled || charts.Count == 0 || exportedCellReferences == null) { return charts; } diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs index ba08ac474..405b8638d 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs @@ -4038,7 +4038,7 @@ bool CanRepeatHeaderWithSegment(int rowIndex) => void DrawRepeatHeaders() { for (int headerIndex = 0; headerIndex < repeatHeaderRowCount; headerIndex++) { - DrawTableRow(headerIndex, renderAsHeader: true); + DrawTableRow(headerIndex, renderAsHeader: true, suppressCellObjects: true); } } @@ -4049,7 +4049,7 @@ void NewTablePage(int rowIndex) { } } - void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int lineCount) { + void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int lineCount, bool suppressCellObjects = false) { bool renderAsFooter = rowIndex >= footerStartRowIndex; bool rowUsesBold = rowBold[rowIndex]; double rowSize = rowSizes[rowIndex]; @@ -4183,7 +4183,7 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l var paragraph = new RichParagraphBlock(StripRunLinksWhenCellLinked(cell.Runs, linkUri, linkDestinationName), MapTableCellAlignment(align), textColor); WriteClippedRichParagraph(sb, paragraph, visibleLines, visibleHeights, currentOpts, firstBaseline, rowSize, rowLeading, currentPage!.Annotations, xi - TableCellClipBleed, cellBottom - TableCellClipBleed, cellWidth + (TableCellClipBleed * 2D), cellHeight + (TableCellClipBleed * 2D), xi + cellPadLeft, innerW); } - if ((cell.Images.Count > 0 || cell.CheckBoxes.Count > 0 || cell.FormFields.Count > 0) && sourceStartLine == 0) { + if (!suppressCellObjects && (cell.Images.Count > 0 || cell.CheckBoxes.Count > 0 || cell.FormFields.Count > 0) && sourceStartLine == 0) { if (CanRenderTableCellCheckBoxInline(cell, lines, sourceStartLine, visibleLineCount)) { RenderTableCellInlineCheckBox(currentPage!, cell, align, lines.Lines[sourceStartLine], xi + cellPadLeft, innerW, firstBaseline); } else { @@ -4193,10 +4193,10 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l } if (HasCellLinkTarget(linkUri, linkDestinationName)) { - double x1 = xi + cellPadLeft; - double x2 = xi + cellPadLeft + innerW; - double y1 = cellBottom + cellPadBottom; - double y2 = y - cellPadTop; + double x1 = xi; + double x2 = xi + cellWidth; + double y1 = cellBottom; + double y2 = y; currentPage!.Annotations.Add(new LinkAnnotation { X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, Uri = linkUri, DestinationName = linkDestinationName, Contents = linkContents ?? cell.Text }); } } @@ -4274,8 +4274,8 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l } } - void DrawTableRow(int rowIndex, bool renderAsHeader) => - DrawTableRowSegment(rowIndex, renderAsHeader, 0, rowLineCounts[rowIndex]); + void DrawTableRow(int rowIndex, bool renderAsHeader, bool suppressCellObjects = false) => + DrawTableRowSegment(rowIndex, renderAsHeader, 0, rowLineCounts[rowIndex], suppressCellObjects); void DrawSplitTableRow(int rowIndex, bool renderAsHeader) { int startLine = 0; @@ -5277,7 +5277,7 @@ bool HasRepeatableHeader() => bool AtContinuationPageTop() => Math.Abs(yCol - yStart) <= 0.001; - void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int lineCount) { + void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int lineCount, bool suppressCellObjects = false) { bool renderAsFooter = rowIndex >= table.FooterStartRowIndex; bool rowUsesBold = table.RowBold[rowIndex]; double rowSize = table.RowSizes[rowIndex]; @@ -5404,7 +5404,7 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, var paragraph = new RichParagraphBlock(StripRunLinksWhenCellLinked(cell.Runs, linkUri, linkDestinationName), MapTableCellAlignment(align), textColor); WriteClippedRichParagraph(sb, paragraph, visibleLines, visibleHeights, currentOpts, firstBaseline, rowSize, rowLeading, currentPage!.Annotations, xi - TableCellClipBleed, cellBottom - TableCellClipBleed, cellWidth + (TableCellClipBleed * 2D), cellHeight + (TableCellClipBleed * 2D), xi + cellPadLeft, innerW); } - if ((cell.Images.Count > 0 || cell.CheckBoxes.Count > 0 || cell.FormFields.Count > 0) && sourceStartLine == 0) { + if (!suppressCellObjects && (cell.Images.Count > 0 || cell.CheckBoxes.Count > 0 || cell.FormFields.Count > 0) && sourceStartLine == 0) { if (CanRenderTableCellCheckBoxInline(cell, lines, sourceStartLine, visibleLineCount)) { RenderTableCellInlineCheckBox(currentPage!, cell, align, lines.Lines[sourceStartLine], xi + cellPadLeft, innerW, firstBaseline); } else { @@ -5414,7 +5414,7 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, } if (HasCellLinkTarget(linkUri, linkDestinationName)) { - currentPage!.Annotations.Add(new LinkAnnotation { X1 = xi + cellPadLeft, Y1 = cellBottom + cellPadBottom, X2 = xi + cellPadLeft + innerW, Y2 = yCol - cellPadTop, Uri = linkUri, DestinationName = linkDestinationName, Contents = linkContents ?? cell.Text }); + currentPage!.Annotations.Add(new LinkAnnotation { X1 = xi, Y1 = cellBottom, X2 = xi + cellWidth, Y2 = yCol, Uri = linkUri, DestinationName = linkDestinationName, Contents = linkContents ?? cell.Text }); } } @@ -5491,8 +5491,8 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, consumed += rowAdvance; } - void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => - DrawColumnTableRowSegment(rowIndex, renderAsHeader, 0, table.RowLineCounts[rowIndex]); + void DrawColumnTableRow(int rowIndex, bool renderAsHeader, bool suppressCellObjects = false) => + DrawColumnTableRowSegment(rowIndex, renderAsHeader, 0, table.RowLineCounts[rowIndex], suppressCellObjects); int rowIndex = line; int rowStartLine = subline; @@ -5516,7 +5516,7 @@ void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => if (repeatHeaderBeforeSegment) { for (int headerIndex = 0; headerIndex < table.RepeatHeaderRowCount; headerIndex++) { - DrawColumnTableRow(headerIndex, renderAsHeader: true); + DrawColumnTableRow(headerIndex, renderAsHeader: true, suppressCellObjects: true); } } @@ -5554,7 +5554,7 @@ void DrawColumnTableRow(int rowIndex, bool renderAsHeader) => if (repeatHeaderBeforeRow) { for (int headerIndex = 0; headerIndex < table.RepeatHeaderRowCount; headerIndex++) { - DrawColumnTableRow(headerIndex, renderAsHeader: true); + DrawColumnTableRow(headerIndex, renderAsHeader: true, suppressCellObjects: true); } } diff --git a/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-professional-report.snapshot.txt b/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-professional-report.snapshot.txt index 4b4beb7a7..960a9b3ff 100644 --- a/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-professional-report.snapshot.txt +++ b/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-professional-report.snapshot.txt @@ -26,18 +26,33 @@ line[16]=y:54.0 x:227.5 w:157.0 size:8.0 text:OfficeIMO.Pdf professional report imageXObjects=4 imageSoftMasks=1 imageDraws=3 -clipOps=1 +clipOps=16 graphicsStateResources=1 graphicsStateUses=1 shadingResources=1 shadingDraws=1 -rectOps=12 +rectOps=27 curveOps=11 -saveOps=28 -restoreOps=28 -visualOps=5 +saveOps=43 +restoreOps=43 +visualOps=20 visualOp[0]=/GS1 gs visualOp[1]=/SH1 sh -visualOp[2]=/Im1 Do -visualOp[3]=/Im2 Do -visualOp[4]=/Im3 Do +visualOp[2]=70 438.3 52.66 28 re W n +visualOp[3]=118.66 438.3 377.43 28 re W n +visualOp[4]=492.09 438.3 49.91 28 re W n +visualOp[5]=70 414.3 52.66 28 re W n +visualOp[6]=118.66 414.3 377.43 28 re W n +visualOp[7]=492.09 414.3 49.91 28 re W n +visualOp[8]=70 376.3 52.66 42 re W n +visualOp[9]=118.66 376.3 377.43 42 re W n +visualOp[10]=492.09 376.3 49.91 42 re W n +visualOp[11]=70 338.3 52.66 42 re W n +visualOp[12]=118.66 338.3 377.43 42 re W n +visualOp[13]=492.09 338.3 49.91 42 re W n +visualOp[14]=70 300.3 52.66 42 re W n +visualOp[15]=118.66 300.3 377.43 42 re W n +visualOp[16]=492.09 300.3 49.91 42 re W n +visualOp[17]=/Im1 Do +visualOp[18]=/Im2 Do +visualOp[19]=/Im3 Do diff --git a/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-representative-report.snapshot.txt b/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-representative-report.snapshot.txt index 20f2381fc..68cb5a5bf 100644 --- a/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-representative-report.snapshot.txt +++ b/OfficeIMO.Tests/Pdf/VisualBaselines/officeimo-pdf-representative-report.snapshot.txt @@ -28,13 +28,25 @@ line[18]=y:54.0 x:411.0 w:129.0 size:8.0 text:OfficeIMO.Pdf visual gate - page 1 imageXObjects=0 imageSoftMasks=0 imageDraws=0 -clipOps=0 +clipOps=12 graphicsStateResources=0 graphicsStateUses=0 shadingResources=0 shadingDraws=0 -rectOps=8 +rectOps=20 curveOps=0 -saveOps=16 -restoreOps=16 -visualOps=0 +saveOps=28 +restoreOps=28 +visualOps=12 +visualOp[0]=70 536.575 160 28.7 re W n +visualOp[1]=226 536.575 160 28.7 re W n +visualOp[2]=382 536.575 160 28.7 re W n +visualOp[3]=70 497.175 160 43.4 re W n +visualOp[4]=226 497.175 160 43.4 re W n +visualOp[5]=382 497.175 160 43.4 re W n +visualOp[6]=70 428.375 160 72.8 re W n +visualOp[7]=226 428.375 160 72.8 re W n +visualOp[8]=382 428.375 160 72.8 re W n +visualOp[9]=70 374.275 160 58.1 re W n +visualOp[10]=226 374.275 160 58.1 re W n +visualOp[11]=382 374.275 160 58.1 re W n diff --git a/OfficeIMO.Word.Pdf/TableLayoutCache.cs b/OfficeIMO.Word.Pdf/TableLayoutCache.cs index 57d877521..3729b879b 100644 --- a/OfficeIMO.Word.Pdf/TableLayoutCache.cs +++ b/OfficeIMO.Word.Pdf/TableLayoutCache.cs @@ -14,11 +14,12 @@ internal static TableLayout GetLayout(WordTable table) { List> rows = TableBuilder.Map(table).ToList(); int columnCount = ResolveColumnCount(table, rows); float[] widths = new float[columnCount]; + float[] gridWidths = new float[columnCount]; List gridColumnWidths = table.GridColumnWidth; if (gridColumnWidths.Count > 0) { - for (int i = 0; i < widths.Length && i < gridColumnWidths.Count; i++) { - widths[i] = gridColumnWidths[i] / 20f; + for (int i = 0; i < gridWidths.Length && i < gridColumnWidths.Count; i++) { + gridWidths[i] = gridColumnWidths[i] / 20f; } } @@ -59,6 +60,12 @@ internal static TableLayout GetLayout(WordTable table) { } } + for (int i = 0; i < widths.Length; i++) { + if (widths[i] <= 0f) { + widths[i] = gridWidths[i]; + } + } + TableLayout layout = new(rows, widths); _cache.Add(table, layout); return layout; diff --git a/Website/.powerforge/seo-baseline.json b/Website/.powerforge/seo-baseline.json index ffde2f604..7304a7c19 100644 --- a/Website/.powerforge/seo-baseline.json +++ b/Website/.powerforge/seo-baseline.json @@ -1,7 +1,7 @@ { "version": 1, - "generatedAtUtc": "2026-05-29T08:58:07.8129559+00:00", - "issueCount": 1878, + "generatedAtUtc": "2026-06-01T06:57:09.0495321Z", + "issueCount": 1910, "keyFormat": "sha256-12-b64url", "issueKeyHashes": [ "_2Biq2BcK_YyEIka", @@ -28,6 +28,7 @@ "_jD2I3qIWRjyIlLg", "_l4MNAUaMp_JW43R", "_NNGnpuJwX7nI44p", + "_onODMZpT3gywdyQ", "_OysuNgZ7YktyM15", "_PdlotR_qnTtZco4", "_pdtyIU7cngvsh0v", @@ -62,6 +63,7 @@ "-Qbka5O-A0Qh7FD7", "-qXz1Ps8HLwTO0nL", "-sVJ_8yFmiTdY8lq", + "-TpOSZq2jk8O9E6a", "-tRNTAVJYqdU3CV-", "-u6H6qRZWncXYO06", "-UxzuQn54GQCvAM6", @@ -72,6 +74,7 @@ "0_UJ-At-RezhBDKA", "01KHltkx8_jp3Hqb", "01uzhxd4DmKtvaNW", + "03yzW4NCQc9MVgxX", "051OkdM44VwwCt-s", "09YakhdRTHaGqCKg", "0bxlWxPZpwHVNTha", @@ -136,6 +139,7 @@ "2PKT9YriCpq0y3Ny", "2pOEp4ONzGCu6cNN", "2RLwJ3cKMikTBku1", + "2wY0-8NHAvhT46Vi", "2XpRI7FKvO0weK6X", "30pl_Eg72HFPTL1r", "31EF7B6K3at8y0UF", @@ -184,8 +188,10 @@ "4Ss_gERjgWe8MBRD", "4SuZa-YMvb6uk_k5", "4tp2oNzhBFh8hlKg", + "4vABL2PYo-eDOVMq", "4VN_KaBZTvEMfyMR", "4w2PYWmlOXrjQIdx", + "4w7xU-yoJs1QHYSE", "4Wzv5-SwEitJsw9-", "4xAibSNb0sOPpqSN", "4y5cUSE1_OvGmdhD", @@ -303,6 +309,7 @@ "8TWj46WmiUlvvN0E", "8U2xpa4_MJlbIfdY", "8USv5h-j12UquEHw", + "8VsvKdP7-AeGlfKP", "8yT9yObxs_jITAwE", "92Odu9VXkPN8p2ce", "949gtKoCiBjwBbn-", @@ -376,6 +383,7 @@ "Aq4G951-Y_tRp7e8", "AQNAzKiSwK5KdRA2", "ar6OHrwTb1TYE8GN", + "aRAsLIklyBS2CTBn", "aRBAyHb7eJBlyBr3", "aRes-FW5N7P9EIq5", "ASivAOfq30yPrs_q", @@ -409,6 +417,7 @@ "b-0f7g_Sgh7cVMCh", "b0P0p9DcdBU7D2Lq", "B1p4In4RUYBIi2iG", + "b2w-HtyVyzgRaS-6", "B4OvDsgVpiHULWG8", "B6HxVV-m4Ne5kvcR", "B6IHp1tUN2MC8RuX", @@ -426,6 +435,7 @@ "bDy09b4xbtWjoyVt", "bE2omB_9DDjq9w8e", "BeeBBKZapGEUe8FW", + "beuCFg2xbOPvQ6gm", "bFC0YvvhfWPupjcZ", "BGjenKz7Dl4SbXbW", "BhIrvNui-lLs9K8i", @@ -502,6 +512,7 @@ "cLcR-H5uMbOpO-pM", "clG1z8iQ8lNj3Rzc", "ClybhwcRavMSZniD", + "Cmd8seSqH0ljVEgp", "cNuvq2ZDAMELpNMF", "co9gFEDIgpsnMvhG", "coBulSIpxqz6ooM9", @@ -546,6 +557,7 @@ "D9EE20BxQyl94e5e", "D9iG4Ke6yBuDQ5Gn", "daJlWzcONsQyvYy3", + "Db9ymGdtPIkRUHJ5", "DBbEhOdzHX_36a6q", "DBKlPdOE3hQWPbUZ", "dCfLW-ta0W1WDncQ", @@ -787,6 +799,7 @@ "GS2rgvc3_JUZzwKc", "GshIJOA3tE6_82CZ", "GtAMykN98B2fxZcx", + "gTjdPF8my8Y1EDwl", "GtJlJ5qMVhBerjo2", "GttyrX1blW9iMjSw", "GUnoIUiCKeF7zwWx", @@ -871,6 +884,7 @@ "iBe02jeOJWZjlXh5", "iCcH76k3jCwhXZRh", "IcIvbeYLdn77GUfx", + "iclPTixHxlCCIIJn", "IcOku_GIz31sBpfH", "ICvffV96x-u2h7Iv", "iDM1hk3SMfCGiMpV", @@ -913,6 +927,7 @@ "iTEh2K0Egs68a78G", "ITwA7kBefatggCtK", "iuVG-2yamnR0EwGL", + "IuwD8Y4HoOOM0hRa", "IW4bsEa6XgHhFDcs", "IXKZNq8XN21GG2bn", "IXMvsGUnIJF179cz", @@ -964,6 +979,7 @@ "JPp22EeWca52xWX6", "jqNXWQprCFzkTP04", "jRlZHCogyD5eXZJc", + "jrQhhzzunN-Om0Wa", "JSDDs4HPdZQwqSzo", "jSQfL2Ctftf7TFlP", "jtqlfbnrw2VzBk_b", @@ -1027,6 +1043,7 @@ "KYkLuL_Y12Y1jMIZ", "KYmCMtEwhNUsfLZe", "l-rZRXamk1NFWXUE", + "L1M47efePGxyShcm", "l4LahiCz93NuYsUE", "L5eN5gdgCjd7yYxA", "l5Y6_vy1kD0ZaDt0", @@ -1099,15 +1116,18 @@ "MeVRdjZPXk1gx5h0", "MEx55lDQZWGsHafE", "Mezbt0I29Upgr6CU", + "mFCbWjBb8yhAVvKG", "MFoEkdkLbQ10rQIU", "mfXmjsAgGk8YxXSD", "mGEE0g8FR0h_Yky2", "mgRWvFlwQt6xk8Ay", "MhBrrnLQom5GKQzQ", "mhtB5oh1s6apjL9q", + "MHVwiSvc5ql2YwWT", "mLAx58yUSZiFx5Lb", "Mmga6xI7jdbh0JLP", "MmZIbt0EHnkSoqI_", + "Mnh5rdfYvGli8weu", "MOmLglD622iyCkOb", "Mozvh1FjK4lAMba0", "mP4Ea12WTyQlJFIX", @@ -1241,7 +1261,9 @@ "oZMzx5mQFUwxy9IU", "P_Lid4nEp2hU8XDi", "P-yrA3h4Zm9pNTbp", + "P3S9ThKJAocnMJ5w", "p5_vXeY0uXQ3B7Qo", + "p8BxbzgIUtN8yAhD", "p8c0ClSyHp7GELTV", "P9sZpyYe6AsRYPH7", "P9ySuWaf3VYKix-u", @@ -1356,6 +1378,7 @@ "R43W6l0Dsxy97BWz", "r4bzkP_pEr-l2_Hk", "R4g4nPY-V-Tip5wC", + "r6DTyhMasmezT8kP", "R6jYva-ot5VDA4ny", "R6N75N6nfJdavJXl", "r7kRCyq7O8hNVEEi", @@ -1417,6 +1440,7 @@ "sAamJjpjzMdrCjW6", "Sax4YbUAkAmjxX5S", "sbkn89PBfJvvxK5A", + "sby_fto_Mmf7oKj2", "Sc-G76MdWdlQ0iPp", "sC3pUV6ftikHeJjL", "sDGoQFGgBikj0W4S", @@ -1465,6 +1489,7 @@ "sy1Sjjbkce3s1aw4", "sYp2I0Fva-zN_KTu", "T_p7fhbnk2L_GhId", + "T0p_irNnetvcd_ZZ", "t2djQk9AU6HaleXx", "T5FCH-bRP-_1gwfM", "T6QmiF-K_gnJqIC1", @@ -1479,6 +1504,7 @@ "TAwdj0P3XF5VsAX1", "tBwMohfbIM7Firqg", "TERX6gLKh5_wYGeY", + "tfFhBZBko-Yi0sGg", "TfTOOCP4KQMWXN49", "tFvzeOufS9wUeQ6V", "TgBuB7zUmgct1uX0", @@ -1550,6 +1576,7 @@ "UBOO2JQW4PwcAq0I", "UbqkCVDFieNd3SJJ", "ubuMfBy20AohkorR", + "UdGSyfIw-W4tA4aL", "uEKhEXO-0Ml2SVeE", "uekxAc3Wn71EkBEV", "UFGvSVh1ZaZTYhMq", @@ -1625,6 +1652,7 @@ "VT1zY261oqFv0fFp", "VtsD2acMkcOOOQhs", "vUo594C4duQ78B79", + "VUQQIVfLfdCrVpzA", "vv3SvF9nezPQhVRE", "vv8wPHngiNo0JUGV", "Vw6kK-Bwdaegn9aD", @@ -1676,6 +1704,8 @@ "wKQZVf6WOFyz6GCI", "WlDl2jcQWh5i-az3", "WlgqMuqa3hKXNy0b", + "wLOxvHoxedoA66f6", + "WLS5YhWlyuptkSdd", "wMaLKzKNxME3mC3r", "WMJFdFUYD3JL9GQd", "Wmtu_4BuwLn2h__J", @@ -1686,6 +1716,7 @@ "wpCQK-Pc7sgJGDAy", "WPQ0n0VgV5Kis7SN", "wQo6ZVDrx4Peb5K8", + "WQRuvppkyYllO_g6", "WS8aPcbFvjtGz86I", "wtdRlaDqhfixTrGb", "WtNgx_fbF8woKIsP", @@ -1754,6 +1785,7 @@ "xVmCRylr03w_CxJ1", "xvoWK3NaZCZo71cI", "Xx9VtC-RmJ37_wMM", + "xXJYhyusZ5CCHg5y", "XyN0CpPBpzkxBd2m", "XYThSlyDomY5YH0j", "XyZEaCA9ozhuPVhC", From 1ec7c3662d31e5d01a48c65e775caca48d628516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 11:23:37 +0200 Subject: [PATCH 07/18] Address PDF export CI and review feedback --- .../ExcelPdfConverterExtensions.cs | 152 +++++++++++++++--- OfficeIMO.Excel/ExcelChart.cs | 39 +++++ .../Rendering/Writer/PdfWriter.Layout.cs | 6 +- OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 42 ++++- 4 files changed, 212 insertions(+), 27 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index bfdd04eb4..826b1bfe2 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -1158,25 +1158,64 @@ private static void AddBarSeries(OfficeDrawing drawing, ExcelChartSnapshot snaps return; } - double max = GetPositiveMax(series); double slot = plotWidth / categories.Count; double groupWidth = slot * 0.68D; - double barWidth = Math.Max(2D, groupWidth / series.Count); bool horizontal = IsBarChart(snapshot.ChartType); + bool stacked = IsStackedBarOrColumnChart(snapshot.ChartType) || IsPercentStackedBarOrColumnChart(snapshot.ChartType); + bool percentStacked = IsPercentStackedBarOrColumnChart(snapshot.ChartType); + double barWidth = Math.Max(2D, stacked ? groupWidth : groupWidth / series.Count); + (double min, double max) = percentStacked + ? (0D, 1D) + : stacked + ? GetStackedSeriesRange(series, categories.Count) + : GetFiniteSeriesRange(series); + min = Math.Min(0D, min); + max = Math.Max(0D, max); + if (max <= min) { + max = min + 1D; + } for (int category = 0; category < categories.Count; category++) { + double positiveBase = 0D; + double negativeBase = 0D; + double percentTotal = percentStacked ? GetPositiveCategoryTotal(series, category) : 0D; for (int s = 0; s < series.Count; s++) { - double value = Math.Max(0D, GetSeriesValue(series[s], category)); + double value = GetSeriesValue(series[s], category); + if (value == 0D) { + continue; + } + + double baseline = 0D; + double plottedValue = value; + if (stacked) { + if (percentStacked) { + plottedValue = percentTotal <= 0D ? 0D : Math.Max(0D, value) / percentTotal; + } + + baseline = plottedValue >= 0D ? positiveBase : negativeBase; + if (plottedValue >= 0D) { + positiveBase += plottedValue; + } else { + negativeBase += plottedValue; + } + } + OfficeColor color = GetChartSeriesColor(s); if (horizontal) { - double rowHeight = Math.Max(2D, plotHeight / categories.Count * 0.68D / series.Count); - double y = plotTop + (plotHeight / categories.Count * category) + (plotHeight / categories.Count * 0.16D) + (rowHeight * s); - double w = Math.Max(1D, plotWidth * value / max); - AddShape(drawing, OfficeShape.Rectangle(w, rowHeight), plotLeft, y, color, null, 0); + double categoryHeight = plotHeight / categories.Count; + double rowHeight = Math.Max(2D, categoryHeight * 0.68D / (stacked ? 1D : series.Count)); + double y = plotTop + (categoryHeight * category) + (categoryHeight * 0.16D) + (stacked ? 0D : rowHeight * s); + double x1 = ToPlotX(baseline, min, max, plotLeft, plotWidth); + double x2 = ToPlotX(stacked ? baseline + plottedValue : plottedValue, min, max, plotLeft, plotWidth); + double x = Math.Min(x1, x2); + double w = Math.Max(1D, Math.Abs(x2 - x1)); + AddShape(drawing, OfficeShape.Rectangle(w, rowHeight), x, y, color, null, 0); } else { - double x = plotLeft + (slot * category) + ((slot - groupWidth) / 2D) + (barWidth * s); - double h = Math.Max(1D, plotHeight * value / max); - double y = plotTop + plotHeight - h; + double x = plotLeft + (slot * category) + ((slot - groupWidth) / 2D) + (stacked ? 0D : barWidth * s); + double y1 = ToPlotY(baseline, min, max, plotTop, plotHeight); + double y2 = ToPlotY(stacked ? baseline + plottedValue : plottedValue, min, max, plotTop, plotHeight); + double y = Math.Min(y1, y2); + double h = Math.Max(1D, Math.Abs(y2 - y1)); AddShape(drawing, OfficeShape.Rectangle(barWidth * 0.88D, h), x, y, color, null, 0); } } @@ -1506,6 +1545,28 @@ private static double GetPositiveCategoryTotal(IReadOnlyList s return total; } + private static (double Min, double Max) GetStackedSeriesRange(IReadOnlyList series, int categoryCount) { + double min = 0D; + double max = 0D; + for (int category = 0; category < categoryCount; category++) { + double positive = 0D; + double negative = 0D; + for (int s = 0; s < series.Count; s++) { + double value = GetSeriesValue(series[s], category); + if (value >= 0D) { + positive += value; + } else { + negative += value; + } + } + + if (positive > max) max = positive; + if (negative < min) min = negative; + } + + return ExpandFlatRange(min, max); + } + private static double GetSeriesValue(ExcelChartSeries series, int index) { double value = index >= 0 && index < series.Values.Count ? series.Values[index] : 0D; return double.IsNaN(value) || double.IsInfinity(value) ? 0D : value; @@ -1751,6 +1812,20 @@ private static bool IsPercentStackedAreaChart(ExcelChartType type) { || type == ExcelChartType.Area3DStacked100; } + private static bool IsStackedBarOrColumnChart(ExcelChartType type) { + return type == ExcelChartType.ColumnStacked + || type == ExcelChartType.Column3DStacked + || type == ExcelChartType.BarStacked + || type == ExcelChartType.Bar3DStacked; + } + + private static bool IsPercentStackedBarOrColumnChart(ExcelChartType type) { + return type == ExcelChartType.ColumnStacked100 + || type == ExcelChartType.Column3DStacked100 + || type == ExcelChartType.BarStacked100 + || type == ExcelChartType.Bar3DStacked100; + } + private static bool IsPieChart(ExcelChartType type) { return type == ExcelChartType.Pie || type == ExcelChartType.Pie3D @@ -1789,6 +1864,7 @@ private static bool HasWorksheetPrintArea(ExcelSheet? workbookSheet, ExcelPdfSav private static SheetExportData ReadSheetExportData(ExcelSheetReader sheet, ExcelSheet? workbookSheet, string exportRange, ExcelPdfSaveOptions options) { string normalizedRange = NormalizeA1Range(exportRange); + A1.TryParseRange(normalizedRange, out int rangeFirstRow, out int rangeFirstColumn, out _, out int rangeLastColumn); RangeExportData bodyRange = ReadRangeExportData(sheet, workbookSheet, normalizedRange, options); object?[,] values = bodyRange.Values; ExcelCellStyleSnapshot?[,]? styles = bodyRange.Styles; @@ -1799,12 +1875,12 @@ private static SheetExportData ReadSheetExportData(ExcelSheetReader sheet, Excel RowLayoutData? rowHeights = bodyRange.RowHeights; int headerRows = options.HeaderRowCount; if (!options.UseWorksheetPrintTitleRows || workbookSheet == null) { - return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, options); + return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, rangeFirstRow, options); } ExcelPrintTitles titles = workbookSheet.GetPrintTitles(); - if (!titles.HasRows || !A1.TryParseRange(normalizedRange, out int rangeFirstRow, out int rangeFirstColumn, out _, out int rangeLastColumn)) { - return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, options); + if (!titles.HasRows) { + return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, rangeFirstRow, options); } int firstTitleRow = titles.FirstRow!.Value; @@ -1835,6 +1911,7 @@ private static SheetExportData ReadSheetExportData(ExcelSheetReader sheet, Excel columnWidths, prependedRowHeights, Math.Max(headerRows, prependedRowCount + overlappingTitleRows), + rangeFirstRow, options); } @@ -1843,17 +1920,17 @@ private static SheetExportData ReadSheetExportData(ExcelSheetReader sheet, Excel headerRows = Math.Max(headerRows, titleRowsInsideRange); } - return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, options); + return CreateSheetExportData(workbookSheet, values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, rangeFirstRow, options); } - private static SheetExportData CreateSheetExportData(ExcelSheet? workbookSheet, object?[,] values, ExcelCellStyleSnapshot?[,]? styles, ExcelHyperlinkSnapshot?[,]? hyperlinks, string?[,]? cellReferences, MergeLayoutData? mergedCells, ColumnLayoutData? columnWidths, RowLayoutData? rowHeights, int headerRows, ExcelPdfSaveOptions options) { + private static SheetExportData CreateSheetExportData(ExcelSheet? workbookSheet, object?[,] values, ExcelCellStyleSnapshot?[,]? styles, ExcelHyperlinkSnapshot?[,]? hyperlinks, string?[,]? cellReferences, MergeLayoutData? mergedCells, ColumnLayoutData? columnWidths, RowLayoutData? rowHeights, int headerRows, int firstBodyRowNumber, ExcelPdfSaveOptions options) { ConditionalFillData? conditionalFills = ReadConditionalFillData( workbookSheet, values, cellReferences, options.UseWorksheetCellStyles); - return new SheetExportData(values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, conditionalFills); + return new SheetExportData(values, styles, hyperlinks, cellReferences, mergedCells, columnWidths, rowHeights, headerRows, firstBodyRowNumber, conditionalFills); } private static RangeExportData ReadRangeExportData(ExcelSheetReader sheet, ExcelSheet? workbookSheet, string normalizedRange, ExcelPdfSaveOptions options) { @@ -2803,6 +2880,10 @@ private static List GetManualRowBreakOffsets(WorksheetPdfExportPlan plan) { int rows = Math.Min(plan.ExportedRows, references.GetLength(0)); foreach (int breakRow in plan.ManualRowBreaks) { + if (breakRow < plan.ExportData.FirstBodyRowNumber) { + continue; + } + for (int row = 0; row < rows; row++) { int originalRow = GetOriginalRowNumber(references, row); if (originalRow > breakRow) { @@ -3618,7 +3699,7 @@ private static string FormatCellValue(object? value, ExcelCellStyleSnapshot? sty } private static string? TryFormatCellValue(object value, ExcelCellStyleSnapshot style, string formatCode) { - string normalized = GetPrimaryNumberFormatSection(formatCode).Trim(); + string normalized = GetNumberFormatSection(formatCode, 0).Trim(); if (normalized.Length == 0 || string.Equals(normalized, "General", StringComparison.OrdinalIgnoreCase) || string.Equals(normalized, "@", StringComparison.Ordinal)) { @@ -3638,9 +3719,22 @@ private static string FormatCellValue(object? value, ExcelCellStyleSnapshot? sty return null; } + normalized = GetNumberFormatSection(formatCode, number < 0D ? 1 : 0).Trim(); + if (normalized.Length == 0 || + string.Equals(normalized, "General", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, "@", StringComparison.Ordinal)) { + return null; + } + if (normalized.IndexOf('%') >= 0) { int decimals = CountDecimalPlaces(normalized); - string numeric = (number * 100D).ToString(decimals > 0 ? "N" + decimals.ToString(CultureInfo.InvariantCulture) : "N0", CultureInfo.InvariantCulture); + bool wrapPercent = ShouldWrapNegativeNumber(normalized, number); + double percentNumber = wrapPercent ? Math.Abs(number) : number; + string numeric = (percentNumber * 100D).ToString(decimals > 0 ? "N" + decimals.ToString(CultureInfo.InvariantCulture) : "N0", CultureInfo.InvariantCulture); + if (wrapPercent) { + return "(" + numeric + "%)"; + } + return numeric + "%"; } @@ -3648,7 +3742,10 @@ private static string FormatCellValue(object? value, ExcelCellStyleSnapshot? sty bool useGrouping = normalized.IndexOf(',') >= 0; int decimalPlaces = CountDecimalPlaces(normalized); string numberFormat = (useGrouping ? "N" : "F") + decimalPlaces.ToString(CultureInfo.InvariantCulture); - return (prefix ?? string.Empty) + number.ToString(numberFormat, CultureInfo.InvariantCulture); + bool wrapNumber = ShouldWrapNegativeNumber(normalized, number); + double displayNumber = wrapNumber ? Math.Abs(number) : number; + string numericValue = (prefix ?? string.Empty) + displayNumber.ToString(numberFormat, CultureInfo.InvariantCulture); + return wrapNumber ? "(" + numericValue + ")" : numericValue; } private static bool TryGetDouble(object value, out double number) { @@ -3679,11 +3776,18 @@ private static bool TryGetDouble(object value, out double number) { } } - private static string GetPrimaryNumberFormatSection(string formatCode) { - int separator = formatCode.IndexOf(';'); - return separator >= 0 ? formatCode.Substring(0, separator) : formatCode; + private static string GetNumberFormatSection(string formatCode, int sectionIndex) { + string[] sections = formatCode.Split(';'); + if (sectionIndex >= 0 && sectionIndex < sections.Length) { + return sections[sectionIndex]; + } + + return sections.Length > 0 ? sections[0] : formatCode; } + private static bool ShouldWrapNegativeNumber(string formatCode, double value) => + value < 0D && formatCode.IndexOf('(') >= 0 && formatCode.IndexOf(')') > formatCode.IndexOf('('); + private static int CountDecimalPlaces(string formatCode) { int decimalIndex = formatCode.IndexOf('.'); if (decimalIndex < 0) { @@ -3883,7 +3987,7 @@ public WorksheetPdfExportPlan(string sheetName, ExcelSheetPageSetup? pageSetup, } private sealed class SheetExportData { - public SheetExportData(object?[,] values, ExcelCellStyleSnapshot?[,]? styles, ExcelHyperlinkSnapshot?[,]? hyperlinks, string?[,]? cellReferences, MergeLayoutData? mergedCells, ColumnLayoutData? columnWidths, RowLayoutData? rowHeights, int headerRowCount, ConditionalFillData? conditionalFills = null) { + public SheetExportData(object?[,] values, ExcelCellStyleSnapshot?[,]? styles, ExcelHyperlinkSnapshot?[,]? hyperlinks, string?[,]? cellReferences, MergeLayoutData? mergedCells, ColumnLayoutData? columnWidths, RowLayoutData? rowHeights, int headerRowCount, int firstBodyRowNumber, ConditionalFillData? conditionalFills = null) { Values = values; Styles = styles; Hyperlinks = hyperlinks; @@ -3892,6 +3996,7 @@ public SheetExportData(object?[,] values, ExcelCellStyleSnapshot?[,]? styles, Ex ColumnWidths = columnWidths; RowHeights = rowHeights; HeaderRowCount = headerRowCount; + FirstBodyRowNumber = firstBodyRowNumber; ConditionalFills = conditionalFills; } @@ -3903,6 +4008,7 @@ public SheetExportData(object?[,] values, ExcelCellStyleSnapshot?[,]? styles, Ex public ColumnLayoutData? ColumnWidths { get; } public RowLayoutData? RowHeights { get; } public int HeaderRowCount { get; } + public int FirstBodyRowNumber { get; } public ConditionalFillData? ConditionalFills { get; } } diff --git a/OfficeIMO.Excel/ExcelChart.cs b/OfficeIMO.Excel/ExcelChart.cs index 4368b41ea..426fefa81 100644 --- a/OfficeIMO.Excel/ExcelChart.cs +++ b/OfficeIMO.Excel/ExcelChart.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; @@ -3701,19 +3702,57 @@ private int GetAnchorColumn() { } private int GetAnchorWidthPixels() { + Xdr.TwoCellAnchor? twoCellAnchor = _frame.Ancestors().FirstOrDefault(); + if (TryGetTwoCellAnchorSizePixels(twoCellAnchor, horizontal: true, out int widthPixels)) { + return widthPixels; + } + long? emu = _frame.Ancestors().FirstOrDefault()?.Extent?.Cx?.Value; return EmuToPixels(emu, 480); } private int GetAnchorHeightPixels() { + Xdr.TwoCellAnchor? twoCellAnchor = _frame.Ancestors().FirstOrDefault(); + if (TryGetTwoCellAnchorSizePixels(twoCellAnchor, horizontal: false, out int heightPixels)) { + return heightPixels; + } + long? emu = _frame.Ancestors().FirstOrDefault()?.Extent?.Cy?.Value; return EmuToPixels(emu, 320); } + private static bool TryGetTwoCellAnchorSizePixels(Xdr.TwoCellAnchor? anchor, bool horizontal, out int pixels) { + pixels = 0; + if (anchor?.FromMarker == null || anchor.ToMarker == null) { + return false; + } + + int from = horizontal ? ParseZeroBasedMarker(anchor.FromMarker.ColumnId?.Text) : ParseZeroBasedMarker(anchor.FromMarker.RowId?.Text); + int to = horizontal ? ParseZeroBasedMarker(anchor.ToMarker.ColumnId?.Text) : ParseZeroBasedMarker(anchor.ToMarker.RowId?.Text); + long fromOffset = ParseEmuOffset(horizontal ? anchor.FromMarker.ColumnOffset?.Text : anchor.FromMarker.RowOffset?.Text); + long toOffset = ParseEmuOffset(horizontal ? anchor.ToMarker.ColumnOffset?.Text : anchor.ToMarker.RowOffset?.Text); + int basePixels = Math.Max(0, to - from) * (horizontal ? 64 : 20); + int offsetPixels = EmuOffsetToPixels(toOffset - fromOffset); + pixels = Math.Max(1, basePixels + offsetPixels); + return pixels > 1; + } + private static int ParseOneBasedMarker(string? value) { return int.TryParse(value, out int zeroBased) && zeroBased >= 0 ? zeroBased + 1 : 1; } + private static int ParseZeroBasedMarker(string? value) { + return int.TryParse(value, out int zeroBased) && zeroBased >= 0 ? zeroBased : 0; + } + + private static long ParseEmuOffset(string? value) { + return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long emu) ? emu : 0L; + } + + private static int EmuOffsetToPixels(long emu) { + return (int)Math.Round(emu / 9525D); + } + private static int EmuToPixels(long? emu, int fallback) { if (!emu.HasValue || emu.Value <= 0) { return fallback; diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs index 405b8638d..6d5be2573 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs @@ -4193,8 +4193,8 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l } if (HasCellLinkTarget(linkUri, linkDestinationName)) { - double x1 = xi; - double x2 = xi + cellWidth; + double x1 = xi + cellPadLeft; + double x2 = xi + cellWidth - cellPadRight; double y1 = cellBottom; double y2 = y; currentPage!.Annotations.Add(new LinkAnnotation { X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, Uri = linkUri, DestinationName = linkDestinationName, Contents = linkContents ?? cell.Text }); @@ -5414,7 +5414,7 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, } if (HasCellLinkTarget(linkUri, linkDestinationName)) { - currentPage!.Annotations.Add(new LinkAnnotation { X1 = xi, Y1 = cellBottom, X2 = xi + cellWidth, Y2 = yCol, Uri = linkUri, DestinationName = linkDestinationName, Contents = linkContents ?? cell.Text }); + currentPage!.Annotations.Add(new LinkAnnotation { X1 = xi + cellPadLeft, Y1 = cellBottom, X2 = xi + cellWidth - cellPadRight, Y2 = yCol, Uri = linkUri, DestinationName = linkDestinationName, Contents = linkContents ?? cell.Text }); } } diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index c0ac6d47c..1022e3da9 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -381,6 +381,39 @@ public void SaveAsPdf_ExcelWorkbook_Honors_Manual_Row_Page_Breaks() { Assert.Contains("AfterBreak", disabledPdf.GetPage(1).Text); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Ignores_Manual_Row_Page_Breaks_Before_Print_Area() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfManualRowBreakBeforePrintArea.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "ManualBreaks")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "TitleOne"); + sheet.Cell(2, 1, "TitleTwo"); + sheet.Cell(10, 1, "BodyHeader"); + sheet.Cell(10, 2, "ValueHeader"); + sheet.Cell(11, 1, "ExportedBody"); + sheet.Cell(11, 2, "BodyValue"); + sheet.AddManualRowPageBreak(5); + document.SetPrintArea(sheet, "A10:B11"); + document.SetPrintTitles(sheet, firstRow: 1, lastRow: 2, firstCol: null, lastCol: null); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(420, 360), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.Equal(1, pdf.NumberOfPages); + string text = pdf.GetPage(1).Text; + Assert.Contains("TitleOne", text); + Assert.Contains("ExportedBody", text); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Honors_Manual_Column_Page_Breaks() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfManualColumnPageBreaks.xlsx"); @@ -495,7 +528,7 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_HeaderFooter_DateTime_And_Fil using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); string text = pdf.GetPage(1).Text; Assert.Contains("Printed " + printedAt.ToString("d", CultureInfo.CurrentCulture), text); - Assert.Contains(printedAt.ToString("t", CultureInfo.CurrentCulture), text); + Assert.Contains(NormalizePdfTextSpaces(printedAt.ToString("t", CultureInfo.CurrentCulture)), NormalizePdfTextSpaces(text)); Assert.Contains("Dir " + Path.GetDirectoryName(Path.GetFullPath(workbookPath)), text); Assert.Contains("File " + Path.GetFileName(workbookPath), text); Assert.Contains("Page 1 of 1", text); @@ -1106,6 +1139,8 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { sheet.CellAt(4, 2).SetValue(new DateTime(2026, 1, 15)).Date("yyyy-mm-dd"); sheet.Cell(5, 1, "Minutes"); sheet.CellAt(5, 2).SetValue(new DateTime(2026, 1, 15, 0, 30, 5)).SetNumberFormat("mm:ss"); + sheet.Cell(6, 1, "Negative"); + sheet.CellAt(6, 2).SetValue(-1234).SetNumberFormat("#,##0;(#,##0)"); ExcelCellStyleSnapshot currencyStyle = sheet.CellAt(2, 2).GetStyle(); Assert.Equal("\"$\"#,##0.00", currencyStyle.NumberFormatCode); @@ -1131,9 +1166,11 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { Assert.Contains("25.7%", text); Assert.Contains("2026-01-15", text); Assert.Contains("30:05", text); + Assert.Contains("(1,234)", text); Assert.DoesNotContain("01:05", text); Assert.DoesNotContain("1234.5", text); Assert.DoesNotContain("0.257", text); + Assert.DoesNotContain("-1,234", text); } [Fact] @@ -1688,4 +1725,7 @@ private static byte[] CreateMinimalRgbPng() { 0, 0, 0, 0 }; } + + private static string NormalizePdfTextSpaces(string text) => + text.Replace('\u00A0', ' ').Replace('\u202F', ' '); } From 73cf18c32363462ab3819701b559044d2109a255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 12:00:26 +0200 Subject: [PATCH 08/18] Address remaining PDF export review feedback --- .../ExcelPdfConverterExtensions.cs | 15 ++++-- .../Rendering/Writer/PdfWriter.Layout.cs | 2 + OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 53 +++++++++++++++++++ .../Pdf/PdfDocVisualQualityTests.cs | 29 ++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index 826b1bfe2..a2004b31c 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -2702,7 +2702,13 @@ private static void CopyRows(string?[,] source, string?[,] target, int targetRow private static string NormalizeA1Range(string range) { string withoutSheet = StripSheetPrefix(range).Replace("$", string.Empty); if (!A1.TryParseRange(withoutSheet, out int r1, out int c1, out int r2, out int c2)) { - throw new ArgumentException("Excel PDF export range must be a valid A1 range.", nameof(range)); + (int Row, int Col) cell = A1.ParseCellRef(withoutSheet); + if (cell.Row <= 0 || cell.Col <= 0) { + throw new ArgumentException("Excel PDF export range must be a valid A1 range.", nameof(range)); + } + + r1 = r2 = cell.Row; + c1 = c2 = cell.Col; } return ToA1Range(r1, c1, r2, c2); @@ -3107,8 +3113,11 @@ private static bool TryGetInternalHyperlinkDestinationName(ExcelHyperlinkSnapsho return false; } - return cellDestinations.TryGetValue(CreateCellDestinationKey(sheetName!, cellReference!), out destinationName) || - sheetDestinations.TryGetValue(sheetName!, out destinationName); + if (!string.IsNullOrEmpty(cellReference)) { + return cellDestinations.TryGetValue(CreateCellDestinationKey(sheetName!, cellReference!), out destinationName); + } + + return sheetDestinations.TryGetValue(sheetName!, out destinationName); } private static bool TryParseInternalSheetName(string? value, out string? sheetName) { diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs index 6d5be2573..d9eafc18a 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs @@ -4163,6 +4163,7 @@ void DrawTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, int l if (cell.Runs.Any(run => run.Bold || rowUsesBold)) { currentPage!.UsedBold = true; usedBold = true; } if (cell.Runs.Any(run => run.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } if (cell.Runs.Any(run => (run.Bold || rowUsesBold) && run.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + MarkRichFonts(cell.Runs); string? linkUri = cell.LinkUri; string? linkDestinationName = cell.LinkDestinationName; string? linkContents = cell.LinkContents; @@ -5384,6 +5385,7 @@ void DrawColumnTableRowSegment(int rowIndex, bool renderAsHeader, int startLine, if (cell.Runs.Any(run => run.Bold || rowUsesBold)) { currentPage!.UsedBold = true; usedBold = true; } if (cell.Runs.Any(run => run.Italic)) { currentPage!.UsedItalic = true; usedItalic = true; } if (cell.Runs.Any(run => (run.Bold || rowUsesBold) && run.Italic)) { currentPage!.UsedBoldItalic = true; usedBoldItalic = true; } + MarkRichFonts(cell.Runs); string? linkUri = cell.LinkUri; string? linkDestinationName = cell.LinkDestinationName; string? linkContents = cell.LinkContents; diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index 1022e3da9..1f2d2b4f5 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -233,6 +233,29 @@ public void SaveAsPdf_ExcelWorkbook_Uses_Worksheet_Print_Area() { Assert.DoesNotContain("OutsideRight", text); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Uses_Single_Cell_Worksheet_Print_Area() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfSingleCellPrintArea.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Report")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "OnlyCell"); + sheet.Cell(2, 1, "OutsideCell"); + document.SetPrintArea(sheet, "A1"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("OnlyCell", text); + Assert.DoesNotContain("OutsideCell", text); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Filters_Images_And_Charts_Outside_Print_Area() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfPrintAreaMedia.xlsx"); @@ -991,6 +1014,36 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Internal_Cell_Hyperlinks_To_Cell_Destin Assert.DoesNotContain(summaryOnly.Links, link => link.IsNamedDestinationLink); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Drops_Internal_Cell_Hyperlinks_To_Unexported_Target_Cells() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfInternalHyperlinkHiddenTarget.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath)) { + ExcelSheet summary = document.AddWorkSheet("Summary"); + summary.Cell(1, 1, "Name"); + summary.SetInternalLink(2, 1, "Details!B200", display: "Open Details B200"); + ExcelSheet details = document.AddWorkSheet("Details"); + details.Cell(1, 1, "Details Header"); + details.Cell(2, 1, "Visible Detail"); + details.Cell(200, 2, "Hidden Target"); + document.SetPrintArea(details, "A1:B2"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = true, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + PdfCore.PdfLogicalDocument logical = PdfCore.PdfLogicalDocument.Load(bytes); + Assert.Contains(logical.NamedDestinations, item => item.Name.IndexOf("details", StringComparison.Ordinal) >= 0); + Assert.DoesNotContain(logical.NamedDestinations, item => item.Name.EndsWith("-b200", StringComparison.Ordinal)); + Assert.DoesNotContain(logical.Links, link => link.IsNamedDestinationLink && link.Contents == "Open Details B200"); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Maps_Cell_Alignment_And_Borders() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfCellAlignmentBorders.xlsx"); diff --git a/OfficeIMO.Tests/Pdf/PdfDocVisualQualityTests.cs b/OfficeIMO.Tests/Pdf/PdfDocVisualQualityTests.cs index 8f2a42de2..7840c923f 100644 --- a/OfficeIMO.Tests/Pdf/PdfDocVisualQualityTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfDocVisualQualityTests.cs @@ -2551,6 +2551,35 @@ public void RowColumnTableWithLinks_RendersTableCellLinkAnnotations() { Assert.InRange(rect.Y2, 0, 792); } + [Fact] + public void Tables_DeclareRichCellRunFontsBeforeRendering() { + byte[] bytes = PdfDoc.Create(new PdfOptions { + DefaultFont = PdfStandardFont.Helvetica, + DefaultFontSize = 11 + }) + .Table(new[] { + new[] { + PdfTableCell.RichTextCell(new[] { TextRun.Normal("Direct table Times", font: PdfStandardFont.TimesRoman) }) + } + }) + .Compose(document => + document.Page(page => + page.Content(content => + content.Row(row => + row.Column(100, column => + column.Table(new[] { + new[] { + PdfTableCell.RichTextCell(new[] { TextRun.Normal("Column table Courier", font: PdfStandardFont.Courier) }) + } + })))))) + .ToBytes(); + + string pdf = Encoding.ASCII.GetString(bytes); + + Assert.Contains("/BaseFont /Times-Roman", pdf, StringComparison.Ordinal); + Assert.Contains("/BaseFont /Courier", pdf, StringComparison.Ordinal); + } + [Fact] public void Bookmark_RejectsInvalidNamesAndDuplicateNames() { Assert.Throws(() => From 460bf5d588c24deb80ff2d3b3146c74b8738ad50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 12:27:08 +0200 Subject: [PATCH 09/18] Address PDF exporter review edge cases --- .../ExcelPdfConverterExtensions.cs | 131 ++++++++++++++-- OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 143 ++++++++++++++++++ .../Pdf/Word.SaveAsPdf.Sections.cs | 36 +++++ .../WordPdfConverterExtensions.Native.cs | 16 +- 4 files changed, 308 insertions(+), 18 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index a2004b31c..da0abf7fe 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -228,7 +228,7 @@ private static HashSet CollectInternalHyperlinkTargetCells(IReadOnlyList continue; } - if (TryParseInternalTarget(hyperlink.Target, out string? sheetName, out string? cellReference)) { + if (TryParseInternalTarget(hyperlink.Target, plan.SheetName, out string? sheetName, out string? cellReference)) { targetCells.Add(CreateCellDestinationKey(sheetName!, cellReference!)); } } @@ -280,7 +280,9 @@ private static void ApplyWorksheetHeaderFooter(PdfCore.PdfPageCompose page, Exce var evenHeaderZones = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentOddEven ? ConvertHeaderFooterZones(headerFooter.EvenHeaderLeft, headerFooter.EvenHeaderCenter, headerFooter.EvenHeaderRight, sheetName, workbookPath, options, "even-page header") : null; - if (HasAnyText(headerZones) || HasAnyText(firstHeaderZones) || HasAnyText(evenHeaderZones) || HasAnyHeaderImage(headerFooter, options)) { + bool hasFirstHeaderVariant = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentFirstPage; + bool hasEvenHeaderVariant = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentOddEven; + if (HasAnyText(headerZones) || hasFirstHeaderVariant || hasEvenHeaderVariant || HasAnyHeaderImage(headerFooter, options)) { page.Header(header => { ApplyHeaderFooterStyle(header, ResolveSharedHeaderFooterStyle(new[] { headerZones, firstHeaderZones, evenHeaderZones }, sheetName, options, "header")); @@ -290,10 +292,14 @@ private static void ApplyWorksheetHeaderFooter(PdfCore.PdfPageCompose page, Exce if (HasAnyText(firstHeaderZones)) { header.FirstPageZones(firstHeaderZones!.Left, firstHeaderZones.Center, firstHeaderZones.Right); + } else if (hasFirstHeaderVariant) { + header.FirstPageText(string.Empty); } if (HasAnyText(evenHeaderZones)) { header.EvenPagesZones(evenHeaderZones!.Left, evenHeaderZones.Center, evenHeaderZones.Right); + } else if (hasEvenHeaderVariant) { + header.EvenPagesText(string.Empty); } AddHeaderImage(header, headerFooter.HeaderLeftImage, options, PdfCore.PdfAlign.Left); @@ -309,7 +315,9 @@ private static void ApplyWorksheetHeaderFooter(PdfCore.PdfPageCompose page, Exce var evenFooterZones = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentOddEven ? ConvertHeaderFooterZones(headerFooter.EvenFooterLeft, headerFooter.EvenFooterCenter, headerFooter.EvenFooterRight, sheetName, workbookPath, options, "even-page footer") : null; - if (HasAnyText(footerZones) || HasAnyText(firstFooterZones) || HasAnyText(evenFooterZones) || HasAnyFooterImage(headerFooter, options)) { + bool hasFirstFooterVariant = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentFirstPage; + bool hasEvenFooterVariant = options.UseWorksheetHeadersAndFooters && headerFooter.DifferentOddEven; + if (HasAnyText(footerZones) || hasFirstFooterVariant || hasEvenFooterVariant || HasAnyFooterImage(headerFooter, options)) { page.Footer(footer => { ApplyHeaderFooterStyle(footer, ResolveSharedHeaderFooterStyle(new[] { footerZones, firstFooterZones, evenFooterZones }, sheetName, options, "footer")); @@ -319,10 +327,14 @@ private static void ApplyWorksheetHeaderFooter(PdfCore.PdfPageCompose page, Exce if (HasAnyText(firstFooterZones)) { footer.FirstPageZones(firstFooterZones!.Left, firstFooterZones.Center, firstFooterZones.Right); + } else if (hasFirstFooterVariant) { + footer.FirstPageText(string.Empty); } if (HasAnyText(evenFooterZones)) { footer.EvenPagesZones(evenFooterZones!.Left, evenFooterZones.Center, evenFooterZones.Right); + } else if (hasEvenFooterVariant) { + footer.EvenPagesText(string.Empty); } AddFooterImage(footer, headerFooter.FooterLeftImage, options, PdfCore.PdfAlign.Left); @@ -363,7 +375,8 @@ private static bool IsPdfSupportedImage(ExcelSheet.HeaderFooterImageSnapshot? im && image.Bytes.Length > 0 && image.WidthPoints > 0D && image.HeightPoints > 0D - && IsPdfSupportedImageContentType(image.ContentType); + && IsPdfSupportedImageContentType(image.ContentType) + && TryValidatePdfImageBytes(image.Bytes, image.ContentType, out _); } private static bool HasAnyText(params string?[] values) { @@ -927,6 +940,15 @@ private static IReadOnlyList ReadWorksheetImages(Excel continue; } + if (!TryValidatePdfImageBytes(bytes, image.ContentType, out string? unsupportedReason)) { + AddWarning( + options, + sheetName, + "WorksheetImage", + $"Worksheet image anchored at {A1.CellReference(image.RowIndex, image.ColumnIndex)} was not exported because the first-party PDF image writer cannot export the image bytes. {unsupportedReason}"); + continue; + } + images.Add(new WorksheetImageExportData(bytes, PixelsToPoints(image.WidthPixels), PixelsToPoints(image.HeightPixels), A1.CellReference(image.RowIndex, image.ColumnIndex))); } @@ -995,6 +1017,45 @@ private static bool IsPdfSupportedImageContentType(string contentType) { || string.Equals(contentType, "image/jpg", StringComparison.OrdinalIgnoreCase); } + private static bool TryValidatePdfImageBytes(byte[] bytes, string contentType, out string? unsupportedReason) { + unsupportedReason = null; + if (!OfficeImageReader.TryIdentify(bytes, null, out OfficeImageInfo imageInfo)) { + unsupportedReason = "Image bytes do not contain a supported image header."; + return false; + } + + if (IsPngContentType(contentType) && imageInfo.Format != OfficeImageFormat.Png) { + unsupportedReason = $"Image bytes were declared as PNG but were detected as {imageInfo.Format}."; + return false; + } + + if (IsJpegContentType(contentType) && imageInfo.Format != OfficeImageFormat.Jpeg) { + unsupportedReason = $"Image bytes were declared as JPEG but were detected as {imageInfo.Format}."; + return false; + } + + try { + _ = new PdfCore.PdfTableCellImage(bytes, 1D, 1D); + return true; + } catch (ArgumentException ex) { + unsupportedReason = ex.Message; + return false; + } catch (InvalidDataException ex) { + unsupportedReason = ex.Message; + return false; + } catch (NotSupportedException ex) { + unsupportedReason = ex.Message; + return false; + } + } + + private static bool IsPngContentType(string contentType) => + string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase); + + private static bool IsJpegContentType(string contentType) => + string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase) + || string.Equals(contentType, "image/jpg", StringComparison.OrdinalIgnoreCase); + private static double PixelsToPoints(int pixels) { return pixels * 72D / 96D; } @@ -1850,12 +1911,39 @@ private static bool IsDoughnutChart(ExcelChartType type) { private static string GetExportRange(ExcelSheetReader sheet, ExcelSheet? workbookSheet, ExcelPdfSaveOptions options) { string? printArea = GetWorksheetPrintArea(workbookSheet, options); if (!string.IsNullOrWhiteSpace(printArea)) { + if (ContainsMultiplePrintAreas(printArea!)) { + AddWarning( + options, + sheet.Name, + "WorksheetPrintArea", + "Multi-area worksheet print areas are not supported by the first-party PDF exporter; exporting the worksheet used range instead."); + return sheet.GetUsedRangeA1(); + } + return NormalizeA1Range(printArea!); } return sheet.GetUsedRangeA1(); } + private static bool ContainsMultiplePrintAreas(string printArea) { + bool inQuotedSheetName = false; + for (int i = 0; i < printArea.Length; i++) { + char current = printArea[i]; + if (current == '\'') { + if (inQuotedSheetName && i + 1 < printArea.Length && printArea[i + 1] == '\'') { + i++; + } else { + inQuotedSheetName = !inQuotedSheetName; + } + } else if (current == ',' && !inQuotedSheetName) { + return true; + } + } + + return false; + } + private static bool HasWorksheetPrintArea(ExcelSheet? workbookSheet, ExcelPdfSaveOptions options) => !string.IsNullOrWhiteSpace(GetWorksheetPrintArea(workbookSheet, options)); @@ -2383,7 +2471,7 @@ private static bool IsWorksheetColumnHidden(IReadOnlyList c int sourceColumn = visibility?.ColumnOffsets[column] ?? column; string reference = A1.CellReference(firstRow + sourceRow, firstColumn + sourceColumn); if (TryGetHyperlink(worksheetHyperlinks, reference, out ExcelHyperlinkSnapshot? hyperlink) && - IsSupportedPdfHyperlink(hyperlink)) { + IsSupportedPdfHyperlink(hyperlink, workbookSheet.Name)) { links[row, column] = hyperlink; hasAnyLink = true; } @@ -2393,12 +2481,12 @@ private static bool IsWorksheetColumnHidden(IReadOnlyList c return hasAnyLink ? links : null; } - private static bool IsSupportedPdfHyperlink(ExcelHyperlinkSnapshot hyperlink) { + private static bool IsSupportedPdfHyperlink(ExcelHyperlinkSnapshot hyperlink, string currentSheetName) { if (hyperlink.IsExternal) { return Uri.TryCreate(hyperlink.Target, UriKind.Absolute, out _); } - return TryParseInternalSheetName(hyperlink.Target, out _); + return TryParseInternalSheetName(hyperlink.Target, currentSheetName, out _); } private static bool TryGetHyperlink(IReadOnlyDictionary hyperlinks, string cellReference, out ExcelHyperlinkSnapshot hyperlink) { @@ -3013,7 +3101,7 @@ private static bool IsMergedCellContinuationColumn(MergeLayoutData? mergedCells, ? destinationName : null; IReadOnlyList? cellImages = GetCellImages(imagesByCellReference, cellReferences, row, column); - cells.Add(CreatePdfCell(text, style, hyperlink, span, sheetDestinations, cellDestinations, cellDestinationName, cellImages)); + cells.Add(CreatePdfCell(text, style, hyperlink, span, sheetDestinations, cellDestinations, sheetName, cellDestinationName, cellImages)); } yield return cells.ToArray(); @@ -3058,12 +3146,12 @@ private static bool IsMergedCellContinuationColumn(MergeLayoutData? mergedCells, : null; } - private static PdfCore.PdfTableCell CreatePdfCell(string text, ExcelCellStyleSnapshot? style, ExcelHyperlinkSnapshot? hyperlink, MergeSpan? span, IReadOnlyDictionary sheetDestinations, IReadOnlyDictionary cellDestinations, string? cellDestinationName, IReadOnlyList? cellImages) { + private static PdfCore.PdfTableCell CreatePdfCell(string text, ExcelCellStyleSnapshot? style, ExcelHyperlinkSnapshot? hyperlink, MergeSpan? span, IReadOnlyDictionary sheetDestinations, IReadOnlyDictionary cellDestinations, string sheetName, string? cellDestinationName, IReadOnlyList? cellImages) { int rowSpan = span?.RowSpan ?? 1; int columnSpan = span?.ColumnSpan ?? 1; PdfCore.PdfColor? textColor = ToPdfColor(style?.FontColorHex); string? linkUri = hyperlink?.IsExternal == true ? hyperlink.Target : null; - string? linkDestinationName = TryGetInternalHyperlinkDestinationName(hyperlink, sheetDestinations, cellDestinations, out string? destinationName) + string? linkDestinationName = TryGetInternalHyperlinkDestinationName(hyperlink, sheetName, sheetDestinations, cellDestinations, out string? destinationName) ? destinationName : null; string? linkContents = linkUri == null && linkDestinationName == null ? null : text; @@ -3107,9 +3195,9 @@ private static bool TryGetCellDestinationName(string?[,]? cellReferences, int ro cellDestinations.TryGetValue(CreateCellDestinationKey(sheetName, cellReference!), out destinationName); } - private static bool TryGetInternalHyperlinkDestinationName(ExcelHyperlinkSnapshot? hyperlink, IReadOnlyDictionary sheetDestinations, IReadOnlyDictionary cellDestinations, out string? destinationName) { + private static bool TryGetInternalHyperlinkDestinationName(ExcelHyperlinkSnapshot? hyperlink, string currentSheetName, IReadOnlyDictionary sheetDestinations, IReadOnlyDictionary cellDestinations, out string? destinationName) { destinationName = null; - if (hyperlink == null || hyperlink.IsExternal || !TryParseInternalTarget(hyperlink.Target, out string? sheetName, out string? cellReference)) { + if (hyperlink == null || hyperlink.IsExternal || !TryParseInternalTarget(hyperlink.Target, currentSheetName, out string? sheetName, out string? cellReference)) { return false; } @@ -3120,8 +3208,8 @@ private static bool TryGetInternalHyperlinkDestinationName(ExcelHyperlinkSnapsho return sheetDestinations.TryGetValue(sheetName!, out destinationName); } - private static bool TryParseInternalSheetName(string? value, out string? sheetName) { - if (TryParseInternalTarget(value, out sheetName, out _)) { + private static bool TryParseInternalSheetName(string? value, string currentSheetName, out string? sheetName) { + if (TryParseInternalTarget(value, currentSheetName, out sheetName, out _)) { return true; } @@ -3129,7 +3217,7 @@ private static bool TryParseInternalSheetName(string? value, out string? sheetNa return false; } - private static bool TryParseInternalTarget(string? value, out string? sheetName, out string? cellReference) { + private static bool TryParseInternalTarget(string? value, string currentSheetName, out string? sheetName, out string? cellReference) { sheetName = null; cellReference = null; if (string.IsNullOrWhiteSpace(value)) { @@ -3138,7 +3226,18 @@ private static bool TryParseInternalTarget(string? value, out string? sheetName, string trimmedValue = value!.Trim(); int bangIndex = trimmedValue.LastIndexOf('!'); - if (bangIndex <= 0 || bangIndex >= trimmedValue.Length - 1) { + if (bangIndex < 0) { + string sameSheetReferenceToken = trimmedValue.Replace("$", string.Empty); + if (!TryGetTopLeftCellReference(sameSheetReferenceToken, out string? sameSheetReference)) { + return false; + } + + sheetName = currentSheetName; + cellReference = sameSheetReference; + return true; + } + + if (bangIndex == 0 || bangIndex >= trimmedValue.Length - 1) { return false; } diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index 1f2d2b4f5..1f3360b4c 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -1,3 +1,4 @@ +using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using OfficeIMO.Excel; using OfficeIMO.Excel.Pdf; @@ -104,6 +105,41 @@ public void SaveAsPdf_ExcelWorkbook_Exports_Worksheet_Images() { Assert.Equal(1, extractedImage.Height); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Warns_And_Skips_Invalid_Worksheet_Image_Bytes() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfInvalidImageBytes.xlsx"); + byte[] invalidPngBytes = new byte[] { + 137, 80, 78, 71, 13, 10, 26, 10, + 0, 0, 0, 13, + 73, 72, 68, 82, + 0, 0, 0, 1, + 0, 0, 0, 1, + 16, 2, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 73, 69, 78, 68, + 0, 0, 0, 0 + }; + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false + }; + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Images")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "ImageMarker"); + sheet.AddImage(2, 1, invalidPngBytes, "image/png", widthPixels: 24, heightPixels: 16, name: "Invalid PNG"); + document.Save(false); + + bytes = document.SaveAsPdf(options); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.Contains("ImageMarker", pdf.GetPage(1).Text); + Assert.Empty(PdfCore.PdfImageExtractor.ExtractImages(bytes)); + Assert.Contains(options.Warnings, warning => warning.SheetName == "Images" && warning.Feature == "WorksheetImage"); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Embeds_Worksheet_Images_In_Anchored_Table_Cells() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfAnchoredImageCell.xlsx"); @@ -256,6 +292,49 @@ public void SaveAsPdf_ExcelWorkbook_Uses_Single_Cell_Worksheet_Print_Area() { Assert.DoesNotContain("OutsideCell", text); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Warns_And_Falls_Back_For_MultiArea_Print_Area() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfMultiAreaPrintArea.xlsx"); + var options = new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + UseWorksheetPrintAreas = true + }; + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Report")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "UsedRangeTop"); + sheet.Cell(2, 2, "AreaOne"); + sheet.Cell(2, 4, "AreaTwo"); + sheet.Cell(5, 5, "UsedRangeBottom"); + document.Save(false); + } + + using (SpreadsheetDocument package = SpreadsheetDocument.Open(workbookPath, true)) { + WorkbookPart workbookPart = package.WorkbookPart ?? throw new InvalidOperationException("Workbook part was not available."); + Workbook workbook = workbookPart.Workbook ?? throw new InvalidOperationException("Workbook root was not available."); + workbook.DefinedNames ??= new DefinedNames(); + workbook.DefinedNames.Append(new DefinedName { + Name = "_xlnm.Print_Area", + LocalSheetId = 0U, + Text = "'Report'!$B$2:$B$2,'Report'!$D$2:$D$2" + }); + workbook.Save(); + } + + using (ExcelDocument document = ExcelDocument.Load(workbookPath)) { + bytes = document.SaveAsPdf(options); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("UsedRangeTop", text); + Assert.Contains("AreaOne", text); + Assert.Contains("AreaTwo", text); + Assert.Contains("UsedRangeBottom", text); + Assert.Contains(options.Warnings, warning => warning.SheetName == "Report" && warning.Feature == "WorksheetPrintArea"); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Filters_Images_And_Charts_Outside_Print_Area() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfPrintAreaMedia.xlsx"); @@ -681,6 +760,44 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_First_And_Even_HeaderFooter_T Assert.Contains("Odd Footer 3", thirdPage); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Preserves_Blank_First_And_Even_HeaderFooter_Variants() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfBlankHeaderFooterVariants.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Ledger")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Entry"); + for (int row = 2; row <= 90; row++) { + sheet.Cell(row, 1, "Ledger row " + row.ToString(CultureInfo.InvariantCulture)); + } + + sheet.SetHeaderFooter(headerCenter: "Odd Header &A", footerCenter: "Odd Footer &P"); + sheet.SetFirstPageHeaderFooter(); + sheet.SetEvenPageHeaderFooter(); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(48) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.True(pdf.NumberOfPages >= 3); + string firstPage = pdf.GetPage(1).Text; + string secondPage = pdf.GetPage(2).Text; + string thirdPage = pdf.GetPage(3).Text; + Assert.DoesNotContain("Odd Header Ledger", firstPage); + Assert.DoesNotContain("Odd Footer 1", firstPage); + Assert.DoesNotContain("Odd Header Ledger", secondPage); + Assert.DoesNotContain("Odd Footer 2", secondPage); + Assert.Contains("Odd Header Ledger", thirdPage); + Assert.Contains("Odd Footer 3", thirdPage); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_HeaderFooter_Images() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterImages.xlsx"); @@ -1014,6 +1131,32 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Internal_Cell_Hyperlinks_To_Cell_Destin Assert.DoesNotContain(summaryOnly.Links, link => link.IsNamedDestinationLink); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_SameSheet_Internal_Cell_Hyperlinks_To_Cell_Destinations() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfSameSheetInternalHyperlinks.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Links")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Top Target"); + sheet.SetInternalLink(2, 1, "A1", display: "Back to Top"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = true, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + PdfCore.PdfLogicalDocument logical = PdfCore.PdfLogicalDocument.Load(bytes); + PdfCore.PdfNamedDestination destination = Assert.Single(logical.NamedDestinations, item => item.Name.EndsWith("-a1", StringComparison.Ordinal)); + PdfCore.PdfLogicalLinkAnnotation link = Assert.Single(logical.GetLinksByDestinationName(destination.Name)); + Assert.Equal("Back to Top", link.Contents); + Assert.Equal(destination.Name, link.DestinationName); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Drops_Internal_Cell_Hyperlinks_To_Unexported_Target_Cells() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfInternalHyperlinkHiddenTarget.xlsx"); diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs index dd31b60c1..854ea4394 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs @@ -531,6 +531,42 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_First_And_Even_HeaderFooter_Varian } } + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Preserves_Blank_First_And_Even_HeaderFooter_Variants() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeBlankHeaderFooterVariants.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeBlankHeaderFooterVariants.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + document.DifferentFirstPage = true; + document.DifferentOddAndEvenPages = true; + + RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddParagraph("Native Odd Header"); + RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph("Native Odd Footer"); + + for (int i = 0; i < 240; i++) { + document.AddParagraph("Native blank variant body"); + } + + document.Save(); + document.SaveAsPdf(pdfPath); + } + + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + Assert.True(pdf.NumberOfPages >= 3); + string firstPageText = pdf.GetPage(1).Text; + string secondPageText = pdf.GetPage(2).Text; + string thirdPageText = pdf.GetPage(3).Text; + + Assert.DoesNotContain("Native Odd Header", firstPageText); + Assert.DoesNotContain("Native Odd Footer", firstPageText); + Assert.DoesNotContain("Native Odd Header", secondPageText); + Assert.DoesNotContain("Native Odd Footer", secondPageText); + Assert.Contains("Native Odd Header", thirdPageText); + Assert.Contains("Native Odd Footer", thirdPageText); + } + } + [Fact] public void SaveAsPdf_OfficeIMOEngine_Records_Warnings_For_Unsupported_HeaderFooter_Content() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterWarnings.docx"); diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs index 4c10dbbc0..1cfdaf315 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -999,7 +999,11 @@ private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, Wor IReadOnlyList firstFooterShapes = GetNativeHeaderFooterShapes(section.Footer?.First); IReadOnlyList evenFooterShapes = GetNativeHeaderFooterShapes(section.Footer?.Even); ApplyNativeHeaderFooterPageNumberStyle(page, defaultHeader, firstHeader, evenHeader, defaultFooter, firstFooter, evenFooter); - if (defaultHeader != null || firstHeader != null || evenHeader != null || + bool hasFirstHeaderVariant = section.DifferentFirstPage || firstHeader != null || firstHeaderImages.Count > 0 || firstHeaderShapes.Count > 0; + bool hasEvenHeaderVariant = section.DifferentOddAndEvenPages || evenHeader != null || evenHeaderImages.Count > 0 || evenHeaderShapes.Count > 0; + bool hasFirstFooterVariant = section.DifferentFirstPage || firstFooter != null || firstFooterImages.Count > 0 || firstFooterShapes.Count > 0; + bool hasEvenFooterVariant = section.DifferentOddAndEvenPages || evenFooter != null || evenFooterImages.Count > 0 || evenFooterShapes.Count > 0; + if (defaultHeader != null || hasFirstHeaderVariant || hasEvenHeaderVariant || defaultHeaderImages.Count > 0 || firstHeaderImages.Count > 0 || evenHeaderImages.Count > 0 || defaultHeaderShapes.Count > 0 || firstHeaderShapes.Count > 0 || evenHeaderShapes.Count > 0) { page.Header(header => { @@ -1012,6 +1016,8 @@ private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, Wor if (firstHeader != null) { header.FirstPageZones(firstHeader.Left, firstHeader.Center, firstHeader.Right); + } else if (hasFirstHeaderVariant) { + header.FirstPageText(string.Empty); } AddNativeHeaderImages(header, firstHeaderImages, W.HeaderFooterValues.First); @@ -1019,6 +1025,8 @@ private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, Wor if (evenHeader != null) { header.EvenPagesZones(evenHeader.Left, evenHeader.Center, evenHeader.Right); + } else if (hasEvenHeaderVariant) { + header.EvenPagesText(string.Empty); } AddNativeHeaderImages(header, evenHeaderImages, W.HeaderFooterValues.Even); @@ -1027,7 +1035,7 @@ private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, Wor } bool includePageNumbers = options?.IncludePageNumbers ?? true; - if (!includePageNumbers && defaultFooter == null && firstFooter == null && evenFooter == null && + if (!includePageNumbers && defaultFooter == null && !hasFirstFooterVariant && !hasEvenFooterVariant && defaultFooterImages.Count == 0 && firstFooterImages.Count == 0 && evenFooterImages.Count == 0 && defaultFooterShapes.Count == 0 && firstFooterShapes.Count == 0 && evenFooterShapes.Count == 0) { return; @@ -1046,6 +1054,8 @@ private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, Wor NativeHeaderFooterText? resolvedFirstFooter = WithNativeFooterPageNumber(firstFooter, includePageNumbers && firstFooter != null, pageNumberFormat); if (resolvedFirstFooter != null) { footer.FirstPageZones(resolvedFirstFooter.Left, resolvedFirstFooter.Center, resolvedFirstFooter.Right); + } else if (hasFirstFooterVariant) { + footer.FirstPageText(string.Empty); } AddNativeFooterImages(footer, firstFooterImages, W.HeaderFooterValues.First); @@ -1054,6 +1064,8 @@ private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, Wor NativeHeaderFooterText? resolvedEvenFooter = WithNativeFooterPageNumber(evenFooter, includePageNumbers && evenFooter != null, pageNumberFormat); if (resolvedEvenFooter != null) { footer.EvenPagesZones(resolvedEvenFooter.Left, resolvedEvenFooter.Center, resolvedEvenFooter.Right); + } else if (hasEvenFooterVariant) { + footer.EvenPagesText(string.Empty); } AddNativeFooterImages(footer, evenFooterImages, W.HeaderFooterValues.Even); From 1980e283dc2776438574129188ce6a599f8bfe17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 12:44:08 +0200 Subject: [PATCH 10/18] Rename Word PDF page setup options --- Docs/officeimo.pdf.roadmap.md | 2 +- Docs/officeimo.pdf.support-matrix.md | 2 +- .../Converters/Pdf/Pdf.FirstPartyOptions.cs | 4 +- .../Converters/Pdf/Pdf.SaveAsPdf.cs | 6 +-- .../Converters/Pdf/Pdf01_SaveAsPdf.cs | 4 +- .../Pdf/PdfDocRasterVisualBaselineTests.cs | 10 ++-- .../Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs | 4 +- .../Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs | 24 ++++----- .../Pdf/Word.SaveAsPdf.Sections.cs | 44 ++++++++-------- OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs | 4 +- .../Pdf/Word.SaveAsPdf.TableStyles.cs | 52 +++++++++---------- OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs | 8 +-- OfficeIMO.Word.Pdf/PdfSaveOptions.cs | 8 +-- OfficeIMO.Word.Pdf/README.md | 2 +- .../WordPdfConverterExtensions.Native.cs | 8 +-- 15 files changed, 91 insertions(+), 91 deletions(-) diff --git a/Docs/officeimo.pdf.roadmap.md b/Docs/officeimo.pdf.roadmap.md index bce31b359..b8ecf03f2 100644 --- a/Docs/officeimo.pdf.roadmap.md +++ b/Docs/officeimo.pdf.roadmap.md @@ -496,7 +496,7 @@ Once the core layout engine is strong, make Office formats target it. #### Word To PDF -- Map Word paragraphs, runs, lists, tables, images, headers, footers, sections, page setup, links, bookmarks, footnotes/endnotes, text boxes, simple shapes, simple inline text content controls, simple body/table-cell check boxes, simple body/table-cell dropdown/combo/date controls, simple body/table-cell/header/footer picture content controls, and simple body/table-cell/header/footer repeating-section text items into the logical PDF model. The default `OfficeIMO.Word.Pdf` path maps basic sections, page setup through first-party `OfficeIMOPageSize` / `OfficeIMOMargins` options with explicit PDF page geometry preserved unless `PdfSaveOptions.Orientation` is set, Word document background color, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, page breaks, headings including linked headings, paragraphs/runs with isolated run color, font-size, superscript/subscript baseline, justified paragraph alignment, text-wrapping breaks, and highlight/background state, paragraph spacing/indents, simple tab stops with leaders/alignment, keep-with-next/keep-lines/widow-control flags, simple shaded and uniform/non-uniform bordered paragraphs, Word horizontal lines and paragraph top/bottom border rules, simple level-0 bullet/decimal lists with rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, footnote/endnote markers, simple tables with supported Word table style presets, rich text runs inside table cells, default and per-cell table margins, table cell spacing, table-level borders, uniform/non-uniform, double, and diagonal cell borders, uniform and non-uniform row heights, row-level break policies, preferred DXA table widths that can fit into narrower section-column frames, explicit autofit-to-contents tables, cell fills, left/center/right table placement, uniform column and non-uniform per-cell horizontal/vertical alignment, simple merged cells, separated first-row visual table styling and repeated leading table header rows, and linked cells including linked merged cells, paragraph-aligned images, simple VML shapes plus the DrawingML preset flow shapes exposed by `WordShape`, simple body text boxes rendered through first-party panel paragraphs, simple body, table-cell, header, and footer picture content controls rendered as first-party PDF images, simple body repeating sections rendered as ordinary first-party PDF paragraphs, simple table-cell repeating sections rendered as first-party rich table-cell text, simple header/footer repeating sections rendered as first-party zone text, simple header/footer text boxes with extractable text routed through first-party zones, simple inline body/table/header/footer text content controls, simple body-level and table-cell Word check boxes mapped to first-party PDF AcroForm check boxes with readback and Poppler raster-baseline coverage in the native Word report fixture, simple body-level and table-cell Word dropdown, combo box, and date picker content controls mapped to first-party PDF AcroForm choice/text fields with readback, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers mapped as static first-party zone text, simple body/table-cell/header/footer OMML equation text mapped as static first-party text, simple default/first/even header and footer text/images/shapes with left/center/right paragraph alignment, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, and simple header/footer table-cell text/images/shapes mapped to first-party zones, simple footnote/endnote markers with end-of-section note text, metadata, and page-number footer settings including Word section page-number starts/styles. +- Map Word paragraphs, runs, lists, tables, images, headers, footers, sections, page setup, links, bookmarks, footnotes/endnotes, text boxes, simple shapes, simple inline text content controls, simple body/table-cell check boxes, simple body/table-cell dropdown/combo/date controls, simple body/table-cell/header/footer picture content controls, and simple body/table-cell/header/footer repeating-section text items into the logical PDF model. The default `OfficeIMO.Word.Pdf` path maps basic sections, page setup through first-party `PageSize` / `Margins` options with explicit PDF page geometry preserved unless `PdfSaveOptions.Orientation` is set, Word document background color, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, page breaks, headings including linked headings, paragraphs/runs with isolated run color, font-size, superscript/subscript baseline, justified paragraph alignment, text-wrapping breaks, and highlight/background state, paragraph spacing/indents, simple tab stops with leaders/alignment, keep-with-next/keep-lines/widow-control flags, simple shaded and uniform/non-uniform bordered paragraphs, Word horizontal lines and paragraph top/bottom border rules, simple level-0 bullet/decimal lists with rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, footnote/endnote markers, simple tables with supported Word table style presets, rich text runs inside table cells, default and per-cell table margins, table cell spacing, table-level borders, uniform/non-uniform, double, and diagonal cell borders, uniform and non-uniform row heights, row-level break policies, preferred DXA table widths that can fit into narrower section-column frames, explicit autofit-to-contents tables, cell fills, left/center/right table placement, uniform column and non-uniform per-cell horizontal/vertical alignment, simple merged cells, separated first-row visual table styling and repeated leading table header rows, and linked cells including linked merged cells, paragraph-aligned images, simple VML shapes plus the DrawingML preset flow shapes exposed by `WordShape`, simple body text boxes rendered through first-party panel paragraphs, simple body, table-cell, header, and footer picture content controls rendered as first-party PDF images, simple body repeating sections rendered as ordinary first-party PDF paragraphs, simple table-cell repeating sections rendered as first-party rich table-cell text, simple header/footer repeating sections rendered as first-party zone text, simple header/footer text boxes with extractable text routed through first-party zones, simple inline body/table/header/footer text content controls, simple body-level and table-cell Word check boxes mapped to first-party PDF AcroForm check boxes with readback and Poppler raster-baseline coverage in the native Word report fixture, simple body-level and table-cell Word dropdown, combo box, and date picker content controls mapped to first-party PDF AcroForm choice/text fields with readback, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers mapped as static first-party zone text, simple body/table-cell/header/footer OMML equation text mapped as static first-party text, simple default/first/even header and footer text/images/shapes with left/center/right paragraph alignment, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, and simple header/footer table-cell text/images/shapes mapped to first-party zones, simple footnote/endnote markers with end-of-section note text, metadata, and page-number footer settings including Word section page-number starts/styles. - Preserve unsupported content with warnings. Initial `PdfSaveOptions.Warnings` diagnostics exist for native Word-to-PDF export, covering unsupported header/footer visual content such as shapes without supported geometry, text boxes without extractable text, SmartArt, equations without extractable text, content controls that are not simple text, picture, static form-control text, or repeating-section mappings, and embedded documents, plus body-level SmartArt, equations without extractable text, content controls that are not mapped to simple text, checkbox, form-field, picture, or repeating-section primitives, embedded documents, and unsupported body elements that the current mapping would otherwise skip silently. - Add visual regression samples for Word-authored and OfficeIMO-authored documents. - Continue improving the first-party `OfficeIMO.Word.Pdf` exporter until supported scenarios have comparable visual quality. diff --git a/Docs/officeimo.pdf.support-matrix.md b/Docs/officeimo.pdf.support-matrix.md index ca6eb8c61..57e3c412b 100644 --- a/Docs/officeimo.pdf.support-matrix.md +++ b/Docs/officeimo.pdf.support-matrix.md @@ -60,7 +60,7 @@ Status values: | Forms | Fill fields | Partial | `PdfFormFiller.FillFields(...)` can update simple AcroForm text/choice-style string values and button name values by fully qualified field name from bytes, paths, or streams, accepts choice values as export values or `/Opt` display text when available while storing the export value and painting display text, supports multi-select choice arrays through `PdfFormFieldValue.FromValues(...)`, updates radio button groups by switching only the matching child widget appearance state on, generates simple text-widget normal appearance streams and simple button-widget Off/selected appearance states for widgets with `/Rect`, marks `/NeedAppearances true`, returns bytes from path inputs, writes path inputs to paths or caller-owned output streams, and rejects signed or active-content PDFs; rich widgets, JavaScript actions, and full appearance regeneration remain roadmap work | | Forms | Flatten forms | Partial | `PdfFormFiller.FlattenFields(...)` and `FillAndFlattenFields(...)` can paint simple text-widget appearances, simple choice-widget text appearances with `/Opt` display text when available for scalar or array selected values, and simple button-widget normal appearance states into page content, generating minimal button appearances when needed, remove those page annotations, and remove the AcroForm tree for parser-supported PDFs from bytes, paths, or streams; helpers return bytes from path inputs and write path inputs to paths or caller-owned output streams; rich/custom appearances, JavaScript actions, and safe complex form preservation remain roadmap work | | Security | Encryption/signatures/redaction | Partial | `PdfInspector.Probe` reports encryption/signature/form/outline/catalog-view-setting/page-label/catalog-name-tree/named-destination/open-action/viewer-preference/tagged-structure/XMP-metadata/catalog-URI/output-intent/embedded-file/optional-content/active-content markers and `PdfInspector.Preflight` turns unsupported markers into read/rewrite decisions with diagnostics plus structured `PdfReadBlockerKind` and `PdfRewriteBlockerKind` entries; encrypted PDFs fail with a clear unsupported diagnostic for parser-supported read/manipulation flows; signed PDFs, form PDFs, complex outline PDFs, complex page-label PDFs, unsupported catalog name-tree PDFs, malformed or unsupported named-destination name-tree PDFs, complex open-action dictionary PDFs, complex viewer-preference PDFs, complex XMP metadata PDFs, complex catalog URI PDFs, tagged PDFs, complex output-intent PDFs, complex embedded-file/associated-file PDFs, complex optional-content PDFs, and active-content PDFs are blocked for rewrite-style manipulation. Simple direct catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array open actions, simple GoTo open-action dictionaries, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, simple associated-file arrays, and simple optional-content metadata are preserved. Creation, validation, redaction, and encrypted reading remain planned | -| Convert | Word to PDF without QuestPDF | Partial | `OfficeIMO.Word.Pdf` now defaults to the first-party engine; `PdfSaveOptions.OfficeIMOPageSize` and `OfficeIMOMargins` provide a QuestPDF-free page setup surface using first-party `OfficeIMO.Pdf` geometry types, with explicit `OfficeIMOPageSize` geometry preserved unless `PdfSaveOptions.Orientation` is set; the current native path maps basic Word sections, page setup, Word document background color, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, page breaks, headings including linked headings, paragraphs/runs with common Word/PDF font family requests mapped to standard Helvetica, Times, and Courier PDF families, isolated run color, font-size, superscript/subscript baseline, justified paragraph alignment, text-wrapping breaks, and highlight/background state, paragraph spacing/indents, simple tab stops with leaders/alignment, keep-with-next/keep-lines/widow-control flags, simple shaded and uniform/non-uniform bordered paragraphs, Word horizontal lines and paragraph top/bottom border rules, simple level-0 bullet/decimal lists with rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, footnote/endnote markers, simple tables with supported Word table style presets, rich text runs inside table cells, default and per-cell table margins, table cell spacing, table-level borders, uniform/non-uniform, double, and diagonal cell borders, uniform and non-uniform row heights, row-level break policies, preferred DXA table widths that fit into narrower native PDF column frames, explicit autofit-to-contents tables, cell fills, left/center/right table placement, uniform column and non-uniform per-cell horizontal/vertical alignment, simple merged cells, separated first-row visual table styling and repeated leading table header rows, and linked cells including linked merged cells, paragraph-aligned images, simple VML shapes plus the DrawingML preset flow shapes exposed by `WordShape`, simple body text boxes rendered through first-party panel paragraphs, simple body, table-cell, header, and footer picture content controls rendered as first-party PDF images, simple body repeating-section text items rendered as ordinary first-party PDF paragraphs, simple table-cell repeating-section text items rendered as first-party rich table-cell text, simple header/footer repeating-section text items rendered as first-party zone text, simple header/footer text boxes with extractable text routed through first-party zones, simple inline body/table/header/footer text content controls, simple body-level and table-cell Word check boxes as inspectable PDF AcroForm check boxes with readback and Poppler raster-baseline coverage in the native Word report fixture, simple body-level and table-cell Word dropdown, combo box, and date picker content controls as inspectable PDF AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple default/first/even header and footer text/images/shapes with left/center/right paragraph alignment, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, and simple header/footer table-cell text/images/shapes mapped to first-party zones, simple footnote/endnote markers with end-of-section note text, metadata, and page-number footer settings including Word section page-number starts/styles into `OfficeIMO.Pdf`; the Poppler lane now includes a daily-layout Word fixture covering TOC, margins, page background color, columns including inline column breaks, separator lines, fonts, colors, lists, links, images, headers/footers, and a table inside the column flow. `PdfSaveOptions.Warnings` records unsupported native header/footer visual content such as shapes without supported geometry, text boxes without extractable text, SmartArt, equations, unsupported content controls, and embedded documents, plus unsupported body SmartArt, equations, unsupported header/footer content controls, embedded documents, and unhandled body elements that are not yet faithfully mapped. The old QuestPDF/SkiaSharp engine path has been removed from `OfficeIMO.Word.Pdf`; remaining work is fidelity and coverage in the first-party exporter | +| Convert | Word to PDF without QuestPDF | Partial | `OfficeIMO.Word.Pdf` now defaults to the first-party engine; `PdfSaveOptions.PageSize` and `Margins` provide a QuestPDF-free page setup surface using first-party `OfficeIMO.Pdf` geometry types, with explicit `PageSize` geometry preserved unless `PdfSaveOptions.Orientation` is set; the current native path maps basic Word sections, page setup, Word document background color, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, page breaks, headings including linked headings, paragraphs/runs with common Word/PDF font family requests mapped to standard Helvetica, Times, and Courier PDF families, isolated run color, font-size, superscript/subscript baseline, justified paragraph alignment, text-wrapping breaks, and highlight/background state, paragraph spacing/indents, simple tab stops with leaders/alignment, keep-with-next/keep-lines/widow-control flags, simple shaded and uniform/non-uniform bordered paragraphs, Word horizontal lines and paragraph top/bottom border rules, simple level-0 bullet/decimal lists with rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, footnote/endnote markers, simple tables with supported Word table style presets, rich text runs inside table cells, default and per-cell table margins, table cell spacing, table-level borders, uniform/non-uniform, double, and diagonal cell borders, uniform and non-uniform row heights, row-level break policies, preferred DXA table widths that fit into narrower native PDF column frames, explicit autofit-to-contents tables, cell fills, left/center/right table placement, uniform column and non-uniform per-cell horizontal/vertical alignment, simple merged cells, separated first-row visual table styling and repeated leading table header rows, and linked cells including linked merged cells, paragraph-aligned images, simple VML shapes plus the DrawingML preset flow shapes exposed by `WordShape`, simple body text boxes rendered through first-party panel paragraphs, simple body, table-cell, header, and footer picture content controls rendered as first-party PDF images, simple body repeating-section text items rendered as ordinary first-party PDF paragraphs, simple table-cell repeating-section text items rendered as first-party rich table-cell text, simple header/footer repeating-section text items rendered as first-party zone text, simple header/footer text boxes with extractable text routed through first-party zones, simple inline body/table/header/footer text content controls, simple body-level and table-cell Word check boxes as inspectable PDF AcroForm check boxes with readback and Poppler raster-baseline coverage in the native Word report fixture, simple body-level and table-cell Word dropdown, combo box, and date picker content controls as inspectable PDF AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple default/first/even header and footer text/images/shapes with left/center/right paragraph alignment, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, and simple header/footer table-cell text/images/shapes mapped to first-party zones, simple footnote/endnote markers with end-of-section note text, metadata, and page-number footer settings including Word section page-number starts/styles into `OfficeIMO.Pdf`; the Poppler lane now includes a daily-layout Word fixture covering TOC, margins, page background color, columns including inline column breaks, separator lines, fonts, colors, lists, links, images, headers/footers, and a table inside the column flow. `PdfSaveOptions.Warnings` records unsupported native header/footer visual content such as shapes without supported geometry, text boxes without extractable text, SmartArt, equations, unsupported content controls, and embedded documents, plus unsupported body SmartArt, equations, unsupported header/footer content controls, embedded documents, and unhandled body elements that are not yet faithfully mapped. The old QuestPDF/SkiaSharp engine path has been removed from `OfficeIMO.Word.Pdf`; remaining work is fidelity and coverage in the first-party exporter | | Convert | Excel to PDF | Partial | `OfficeIMO.Excel.Pdf` provides the first Excel-to-PDF package surface. The exporter maps selected or all visible workbook worksheets into first-party `OfficeIMO.Pdf` headings and tables, honors worksheet print areas, worksheet orientation, worksheet margins, hidden workbook worksheet filtering for default all-sheet exports, hidden worksheet rows and columns, repeated print-title rows through the PDF table header model, manual worksheet row and column page breaks as explicit PDF page breaks while preserving repeated header/title rows across split table chunks, simple worksheet header/footer text zones with first-page and even-page text variants plus page-number, page-count, sheet-name, date, time, workbook file-name, and workbook path tokens, simple line-level header/footer font family/style, font size, and RGB text color when representable as one first-party PDF header/footer line style, and supported header/footer images, worksheet merged cells through PDF table column/row spans, supported worksheet drawing images anchored into exported PDF table cells when the anchor cell is exported and otherwise emitted as PDF flow images in anchor order, supported column/bar/line/area/scatter/radar/pie/doughnut worksheet chart families as first-party vector drawing snapshots when chart data can be read, and common number formats plus basic explicit cell font emphasis, font color, fill color, two-color conditional color-scale fills, conditional data bars, conditional icon-set indicators, horizontal/vertical alignment, simple cell borders including dashed, dotted, dash-dot, double, and diagonal strokes, external cell hyperlinks, internal workbook links as sheet-level PDF named destinations, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, and fit-to-width table sizing through first-party table/rich-text/image primitives; supports explicit page size/margin options through reusable PDF geometry types; can return bytes or write to paths/streams; and now has a Poppler raster baseline for a daily two-sheet workbook covering worksheet header/footer text/images, orientation/margins, merged title cells, fills/borders, number formats, explicit row/column sizing, hidden row/column filtering, anchored worksheet images, chart snapshots, and internal/external links. `ExcelPdfSaveOptions.Warnings` records unsupported or simplified export features such as mixed or rich per-run worksheet header/footer formatting, unsupported or unreadable worksheet/header/footer images, unsupported or unreadable chart snapshots, and row truncation from `MaxRowsPerSheet`. Richer worksheet header/footer formatting beyond the current line-level style mapping, cell-specific internal workbook-link destinations, fit-to-height and automatic multi-page pagination/scaling, richer worksheet image placement fidelity beyond exported table-cell anchors, richer chart fidelity beyond initial column/bar/line/area/scatter/radar/pie/doughnut snapshots, richer cell style fidelity such as additional conditional formats and locale-specific formats, richer merged-cell edge cases, and broader unsupported-feature diagnostics remain roadmap work | | Convert | PowerPoint to PDF | Planned | Later phases after the PDF layout engine matures | diff --git a/OfficeIMO.Examples/Converters/Pdf/Pdf.FirstPartyOptions.cs b/OfficeIMO.Examples/Converters/Pdf/Pdf.FirstPartyOptions.cs index ab5fd39d9..48a734c84 100644 --- a/OfficeIMO.Examples/Converters/Pdf/Pdf.FirstPartyOptions.cs +++ b/OfficeIMO.Examples/Converters/Pdf/Pdf.FirstPartyOptions.cs @@ -15,8 +15,8 @@ public static void Example_SaveAsPdfWithFirstPartyOptions(string folderPath, boo document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - OfficeIMOPageSize = OfficeIMO.Pdf.PageSizes.Letter, - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Narrow + PageSize = OfficeIMO.Pdf.PageSizes.Letter, + Margins = OfficeIMO.Pdf.PageMargins.Narrow }); } diff --git a/OfficeIMO.Examples/Converters/Pdf/Pdf.SaveAsPdf.cs b/OfficeIMO.Examples/Converters/Pdf/Pdf.SaveAsPdf.cs index 06b75a2e2..09dbd04b2 100644 --- a/OfficeIMO.Examples/Converters/Pdf/Pdf.SaveAsPdf.cs +++ b/OfficeIMO.Examples/Converters/Pdf/Pdf.SaveAsPdf.cs @@ -55,9 +55,9 @@ public static void Example_SaveAsPdf(string folderPath, bool openWord) { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - OfficeIMOPageSize = OfficeIMO.Pdf.PageSizes.A4, + PageSize = OfficeIMO.Pdf.PageSizes.A4, Orientation = PdfPageOrientation.Landscape, - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.FromCentimeters( + Margins = OfficeIMO.Pdf.PageMargins.FromCentimeters( left: 2, top: 2, right: 2, @@ -77,7 +77,7 @@ public static void Example_SaveAsPdfInMemory(string folderPath, bool openWord) { using (MemoryStream pdfStream = new MemoryStream()) { document.SaveAsPdf(pdfStream, new PdfSaveOptions { - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(300, 500) + PageSize = new OfficeIMO.Pdf.PageSize(300, 500) }); File.WriteAllBytes(pdfPath, pdfStream.ToArray()); } diff --git a/OfficeIMO.Examples/Converters/Pdf/Pdf01_SaveAsPdf.cs b/OfficeIMO.Examples/Converters/Pdf/Pdf01_SaveAsPdf.cs index b947cfc8d..9bf51b7a0 100644 --- a/OfficeIMO.Examples/Converters/Pdf/Pdf01_SaveAsPdf.cs +++ b/OfficeIMO.Examples/Converters/Pdf/Pdf01_SaveAsPdf.cs @@ -47,8 +47,8 @@ public static void Example(string folderPath, bool openWord) { string outputPath = Path.Combine(folderPath, "SaveAsPdf.pdf"); doc.SaveAsPdf(outputPath, new PdfSaveOptions { Orientation = PdfPageOrientation.Portrait, - OfficeIMOPageSize = OfficeIMO.Pdf.PageSizes.A4, - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.FromCentimeters( + PageSize = OfficeIMO.Pdf.PageSizes.A4, + Margins = OfficeIMO.Pdf.PageMargins.FromCentimeters( left: 1, top: 3, right: 2, diff --git a/OfficeIMO.Tests/Pdf/PdfDocRasterVisualBaselineTests.cs b/OfficeIMO.Tests/Pdf/PdfDocRasterVisualBaselineTests.cs index b11608993..d472b71d5 100644 --- a/OfficeIMO.Tests/Pdf/PdfDocRasterVisualBaselineTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfDocRasterVisualBaselineTests.cs @@ -405,8 +405,8 @@ private static byte[] CreateNativeWordReport() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(612, 792), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(36) + PageSize = new OfficeIMO.Pdf.PageSize(612, 792), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(36) }); } @@ -512,7 +512,7 @@ private static byte[] CreateNativeWordDailyLayout() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PageSize(612, 792) + PageSize = new PageSize(612, 792) }); } @@ -546,8 +546,8 @@ private static byte[] CreateNativeWordTableCellPictureControl() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PageSize(612, 360), - OfficeIMOMargins = PageMargins.Uniform(36) + PageSize = new PageSize(612, 360), + Margins = PageMargins.Uniform(36) }); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs index ef34209e5..c78456664 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs @@ -54,8 +54,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Aligned_Images() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(300, 260), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + PageSize = new OfficeIMO.Pdf.PageSize(300, 260), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(30) }); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs index e9e45ef4c..0fed60ca6 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs @@ -196,8 +196,8 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_Justified_Paragraphs() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(240, 360), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(24) + PageSize = new OfficeIMO.Pdf.PageSize(240, 360), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(24) }); } @@ -721,8 +721,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Word_Horizontal_Line() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(300, 180), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + PageSize = new OfficeIMO.Pdf.PageSize(300, 180), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(30) }); } @@ -758,8 +758,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Bottom_Border_As_Rule() document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + PageSize = new OfficeIMO.Pdf.PageSize(360, 200), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(30) }); } @@ -794,8 +794,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Top_Border_As_Rule() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + PageSize = new OfficeIMO.Pdf.PageSize(360, 200), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(30) }); } @@ -830,8 +830,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_NonUniform_Paragraph_Borders_As_Pa document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + PageSize = new OfficeIMO.Pdf.PageSize(360, 200), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(30) }); } @@ -862,8 +862,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Tab_Leaders() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 180), - OfficeIMOMargins = PageMargins.Uniform(36) + PageSize = new OfficeIMO.Pdf.PageSize(360, 180), + Margins = PageMargins.Uniform(36) }); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs index 854ea4394..802f6726b 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs @@ -104,8 +104,8 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_Paragraph_Alignment_To_Z document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(400, 300), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(50) + PageSize = new PdfCore.PageSize(400, 300), + Margins = PdfCore.PageMargins.Uniform(50) }); } @@ -147,8 +147,8 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_Table_Cells_To_Zones() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(520, 320), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(60) + PageSize = new PdfCore.PageSize(520, 320), + Margins = PdfCore.PageMargins.Uniform(60) }); } @@ -180,7 +180,7 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_Table_Cells_To_Zones() { } [Fact] - public void SaveAsPdf_OfficeIMOEngine_Uses_OfficeIMO_Page_Setup_Options() { + public void SaveAsPdf_OfficeIMOEngine_Uses_Explicit_Pdf_Page_Setup_Options() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativeOfficeIMOPageSetup.docx"); string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeOfficeIMOPageSetup.pdf"); @@ -189,8 +189,8 @@ public void SaveAsPdf_OfficeIMOEngine_Uses_OfficeIMO_Page_Setup_Options() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(240, 320), - OfficeIMOMargins = new PdfCore.PageMargins(80, 36, 36, 36) + PageSize = new PdfCore.PageSize(240, 320), + Margins = new PdfCore.PageMargins(80, 36, 36, 36) }); } @@ -205,7 +205,7 @@ public void SaveAsPdf_OfficeIMOEngine_Uses_OfficeIMO_Page_Setup_Options() { } [Fact] - public void SaveAsPdf_OfficeIMOEngine_Preserves_Explicit_OfficeIMO_Page_Size_Geometry() { + public void SaveAsPdf_OfficeIMOEngine_Preserves_Explicit_Pdf_Page_Size_Geometry() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativeExplicitPageGeometry.docx"); string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeExplicitPageGeometry.pdf"); @@ -214,8 +214,8 @@ public void SaveAsPdf_OfficeIMOEngine_Preserves_Explicit_OfficeIMO_Page_Size_Geo document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(420, 240), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + PageSize = new PdfCore.PageSize(420, 240), + Margins = PdfCore.PageMargins.Uniform(36) }); } @@ -290,8 +290,8 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_Word_Section_Columns_To_RowColumn_Flo document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(612, 792), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + PageSize = new PdfCore.PageSize(612, 792), + Margins = PdfCore.PageMargins.Uniform(36) }); } @@ -331,8 +331,8 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_Unequal_Word_Section_Column_Widths() document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(612, 792), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + PageSize = new PdfCore.PageSize(612, 792), + Margins = PdfCore.PageMargins.Uniform(36) }); } @@ -367,8 +367,8 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_Word_Section_Column_Separator() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(612, 792), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + PageSize = new PdfCore.PageSize(612, 792), + Margins = PdfCore.PageMargins.Uniform(36) }); } @@ -399,8 +399,8 @@ public void SaveAsPdf_OfficeIMOEngine_Distributes_Word_Section_Columns_Without_E document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(612, 792), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + PageSize = new PdfCore.PageSize(612, 792), + Margins = PdfCore.PageMargins.Uniform(36) }); } @@ -434,8 +434,8 @@ public void SaveAsPdf_OfficeIMOEngine_Keeps_Automatic_Column_Headings_With_Follo document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(612, 792), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + PageSize = new PdfCore.PageSize(612, 792), + Margins = PdfCore.PageMargins.Uniform(36) }); } @@ -470,8 +470,8 @@ public void SaveAsPdf_OfficeIMOEngine_Splits_Inline_Word_Column_Breaks() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(612, 792), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + PageSize = new PdfCore.PageSize(612, 792), + Margins = PdfCore.PageMargins.Uniform(36) }); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs index d6cb05475..cfe1d7950 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Streams.cs @@ -100,8 +100,8 @@ public async Task Test_WordDocument_SaveAsPdfAsync_OfficeIMOEngine_ToBytes_UsesN byte[] bytes = await document.SaveAsPdfAsync(new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(240, 320), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(36) + PageSize = new PdfCore.PageSize(240, 320), + Margins = PdfCore.PageMargins.Uniform(36) }, CancellationToken.None); Assert.True(bytes.Length > 0); diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs index ea10af133..4cb10af3e 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.TableStyles.cs @@ -105,8 +105,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_NonUniform_Borders() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + PageSize = new OfficeIMO.Pdf.PageSize(360, 200), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(30) }); } @@ -155,8 +155,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Double_And_Diagonal_Bor document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new OfficeIMO.Pdf.PageSize(360, 200), - OfficeIMOMargins = OfficeIMO.Pdf.PageMargins.Uniform(30) + PageSize = new OfficeIMO.Pdf.PageSize(360, 200), + Margins = OfficeIMO.Pdf.PageMargins.Uniform(30) }); } @@ -255,8 +255,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Default_Cell_Margins() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(400, 500), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + PageSize = new PdfCore.PageSize(400, 500), + Margins = PdfCore.PageMargins.Uniform(40) }); } @@ -290,8 +290,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Margins() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(400, 500), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + PageSize = new PdfCore.PageSize(400, 500), + Margins = PdfCore.PageMargins.Uniform(40) }); } @@ -323,8 +323,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_Spacing() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(420, 500), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + PageSize = new PdfCore.PageSize(420, 500), + Margins = PdfCore.PageMargins.Uniform(40) }); } @@ -355,8 +355,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Row_Height() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(400, 500), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + PageSize = new PdfCore.PageSize(400, 500), + Margins = PdfCore.PageMargins.Uniform(40) }); } @@ -386,8 +386,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_NonUniform_Row_Heights() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(320, 260), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(30) + PageSize = new PdfCore.PageSize(320, 260), + Margins = PdfCore.PageMargins.Uniform(30) }); } @@ -521,8 +521,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Cell_NonUniform_Alignment() document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(360, 260), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(30) + PageSize = new PdfCore.PageSize(360, 260), + Margins = PdfCore.PageMargins.Uniform(30) }); } @@ -621,8 +621,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Header_Row() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(260, 220), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(12) + PageSize = new PdfCore.PageSize(260, 220), + Margins = PdfCore.PageMargins.Uniform(12) }); } @@ -665,8 +665,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Multiple_Header_Rows() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(260, 220), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(12) + PageSize = new PdfCore.PageSize(260, 220), + Margins = PdfCore.PageMargins.Uniform(12) }); } @@ -711,8 +711,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_First_Row_Style_Without_Repe document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(260, 220), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(12) + PageSize = new PdfCore.PageSize(260, 220), + Margins = PdfCore.PageMargins.Uniform(12) }); } @@ -748,8 +748,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Placement() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(400, 500), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + PageSize = new PdfCore.PageSize(400, 500), + Margins = PdfCore.PageMargins.Uniform(40) }); } @@ -786,8 +786,8 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Table_Preferred_Dxa_Width() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { IncludePageNumbers = false, - OfficeIMOPageSize = new PdfCore.PageSize(400, 500), - OfficeIMOMargins = PdfCore.PageMargins.Uniform(40) + PageSize = new PdfCore.PageSize(400, 500), + Margins = PdfCore.PageMargins.Uniform(40) }); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs index 2541f3ad5..f474edf39 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.cs @@ -250,7 +250,7 @@ public void Test_WordDocument_SaveAsPdf_PageOrientation(PdfPageOrientation orien document.SaveAsPdf(pdfPath, new PdfSaveOptions { Orientation = orientation, - OfficeIMOPageSize = PdfCore.PageSizes.A4 + PageSize = PdfCore.PageSizes.A4 }); } @@ -306,7 +306,7 @@ public void Test_WordDocument_SaveAsPdf_CustomPageSize() { document.AddParagraph("Hello World"); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - OfficeIMOPageSize = size + PageSize = size }); } @@ -331,7 +331,7 @@ public void Test_WordDocument_SaveAsPdf_CustomMargins() { document.AddParagraph("Hello World"); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - OfficeIMOMargins = PdfCore.PageMargins.UniformCentimeters(marginCm) + Margins = PdfCore.PageMargins.UniformCentimeters(marginCm) }); } @@ -353,7 +353,7 @@ public void Test_WordDocument_SaveAsPdf_MixedMargins() { document.Save(); document.SaveAsPdf(pdfPath, new PdfSaveOptions { - OfficeIMOMargins = PdfCore.PageMargins.FromCentimeters(1, 3, 2, 2) + Margins = PdfCore.PageMargins.FromCentimeters(1, 3, 2, 2) }); } diff --git a/OfficeIMO.Word.Pdf/PdfSaveOptions.cs b/OfficeIMO.Word.Pdf/PdfSaveOptions.cs index a910951f7..3376d1c9e 100644 --- a/OfficeIMO.Word.Pdf/PdfSaveOptions.cs +++ b/OfficeIMO.Word.Pdf/PdfSaveOptions.cs @@ -12,14 +12,14 @@ public class PdfSaveOptions { public string? FontFamily { get; set; } /// - /// Optional first-party page size in PDF points. The supplied geometry is preserved unless is also set. + /// Optional page size in PDF points. The supplied geometry is preserved unless is also set. /// - public PdfCore.PageSize? OfficeIMOPageSize { get; set; } + public PdfCore.PageSize? PageSize { get; set; } /// - /// Optional first-party page margins in PDF points. + /// Optional page margins in PDF points. /// - public PdfCore.PageMargins? OfficeIMOMargins { get; set; } + public PdfCore.PageMargins? Margins { get; set; } /// /// Optional page orientation for the generated PDF. diff --git a/OfficeIMO.Word.Pdf/README.md b/OfficeIMO.Word.Pdf/README.md index 38c61a0bc..6430a2795 100644 --- a/OfficeIMO.Word.Pdf/README.md +++ b/OfficeIMO.Word.Pdf/README.md @@ -2,7 +2,7 @@ Utilities bridging OfficeIMO outputs with PDF workflows. -`OfficeIMO.Word.Pdf` defaults to the first-party `OfficeIMO.Pdf` exporter. Use `PdfSaveOptions.OfficeIMOPageSize` and `OfficeIMOMargins` for page setup; they use `OfficeIMO.Pdf.PageSize` and `OfficeIMO.Pdf.PageMargins` in PDF points, and `OfficeIMOPageSize` is preserved as exact page geometry unless `PdfSaveOptions.Orientation` is also set. The native path maps Word document background color, common Word/PDF font-family requests to standard Helvetica, Times, and Courier PDF families for document defaults and paragraph/run text, scoped Word paragraph/run font sizes, superscript/subscript baseline, highlights, justified paragraphs, text-wrapping breaks, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, Word horizontal lines, paragraph top/bottom border rules, simple non-uniform paragraph side borders, uniform/non-uniform, double, and diagonal table cell borders, default and per-cell table margins, uniform/non-uniform table row heights, row-level table break policies, separated first-row visual table styling and contiguous leading repeated table header rows, uniform column and non-uniform per-cell table alignment, fixed-width Word tables fit into narrower native PDF column frames, footnote/endnote markers with simple note text, simple text boxes, simple VML shapes plus the DrawingML preset shapes exposed by `WordShape`, simple body, table-cell, header, and footer picture content controls, simple body, table-cell, header, and footer repeating-section text items, simple inline body/table/header/footer text content controls, simple body and table-cell Word check boxes as first-party PDF AcroForm check boxes, simple body-level and table-cell Word dropdown, combo box, and date picker controls as first-party PDF AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple body/table-cell/header/footer OMML equation text as static first-party text, Word section page-number starts/styles, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, simple header/footer paragraph alignment, simple header/footer paragraph and table-cell images/shapes, simple header/footer table-cell zones, and rich table-cell runs into first-party rich text primitives, records `PdfSaveOptions.Warnings` for unsupported native header/footer and body content that cannot be mapped faithfully yet, and has initial Poppler raster baselines for Word-origin report, daily-layout, and table-cell picture-control fixtures. +`OfficeIMO.Word.Pdf` defaults to the first-party `OfficeIMO.Pdf` exporter. Use `PdfSaveOptions.PageSize` and `Margins` for page setup; they use `OfficeIMO.Pdf.PageSize` and `OfficeIMO.Pdf.PageMargins` in PDF points, and `PageSize` is preserved as exact page geometry unless `PdfSaveOptions.Orientation` is also set. The native path maps Word document background color, common Word/PDF font-family requests to standard Helvetica, Times, and Courier PDF families for document defaults and paragraph/run text, scoped Word paragraph/run font sizes, superscript/subscript baseline, highlights, justified paragraphs, text-wrapping breaks, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, Word horizontal lines, paragraph top/bottom border rules, simple non-uniform paragraph side borders, uniform/non-uniform, double, and diagonal table cell borders, default and per-cell table margins, uniform/non-uniform table row heights, row-level table break policies, separated first-row visual table styling and contiguous leading repeated table header rows, uniform column and non-uniform per-cell table alignment, fixed-width Word tables fit into narrower native PDF column frames, footnote/endnote markers with simple note text, simple text boxes, simple VML shapes plus the DrawingML preset shapes exposed by `WordShape`, simple body, table-cell, header, and footer picture content controls, simple body, table-cell, header, and footer repeating-section text items, simple inline body/table/header/footer text content controls, simple body and table-cell Word check boxes as first-party PDF AcroForm check boxes, simple body-level and table-cell Word dropdown, combo box, and date picker controls as first-party PDF AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple body/table-cell/header/footer OMML equation text as static first-party text, Word section page-number starts/styles, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, simple header/footer paragraph alignment, simple header/footer paragraph and table-cell images/shapes, simple header/footer table-cell zones, and rich table-cell runs into first-party rich text primitives, records `PdfSaveOptions.Warnings` for unsupported native header/footer and body content that cannot be mapped faithfully yet, and has initial Poppler raster baselines for Word-origin report, daily-layout, and table-cell picture-control fixtures. Examples are available in `OfficeIMO.Examples`. diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs index 1cfdaf315..98359fb41 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -5456,8 +5456,8 @@ private static int GetHeadingLevel(WordParagraph paragraph) { private static PdfCore.PageSize GetNativePageSize(WordSection section, PdfSaveOptions? options) { PdfCore.PageSize size; - if (options?.OfficeIMOPageSize != null) { - size = options.OfficeIMOPageSize.Value; + if (options?.PageSize != null) { + size = options.PageSize.Value; if (options.Orientation == null) { return size; } @@ -5500,8 +5500,8 @@ private static PdfCore.PageSize MapNativePageSize(WordPageSize pageSize) => }; private static PdfCore.PageMargins GetNativeMargins(WordSection section, PdfSaveOptions? options) { - if (options?.OfficeIMOMargins != null) { - return options.OfficeIMOMargins.Value; + if (options?.Margins != null) { + return options.Margins.Value; } return new PdfCore.PageMargins( From cccf7ae5e6ffb749135653975a4df39421a520ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 12:54:20 +0200 Subject: [PATCH 11/18] Address PDF export review feedback --- .../ExcelPdfConverterExtensions.cs | 18 ++++-- OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 56 +++++++++++++++++ .../Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs | 31 +++++++++ .../Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs | 37 +++++++++++ .../Pdf/Word.SaveAsPdf.Sections.cs | 40 ++++++++++++ .../WordPdfConverterExtensions.Native.cs | 63 ++++++++++++++----- 6 files changed, 224 insertions(+), 21 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index da0abf7fe..409ba83cf 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -1341,15 +1341,15 @@ private static void AddLineSeries(OfficeDrawing drawing, ExcelChartSnapshot snap return; } - double max = GetPositiveMax(series); + (double min, double max) = GetFiniteSeriesRange(series); double step = plotWidth / (categories.Count - 1); for (int s = 0; s < series.Count; s++) { OfficeColor color = GetChartSeriesColor(s); for (int i = 1; i < categories.Count; i++) { double x1 = plotLeft + step * (i - 1); - double y1 = plotTop + plotHeight - (plotHeight * Math.Max(0D, GetSeriesValue(series[s], i - 1)) / max); + double y1 = ToPlotY(GetSeriesValue(series[s], i - 1), min, max, plotTop, plotHeight); double x2 = plotLeft + step * i; - double y2 = plotTop + plotHeight - (plotHeight * Math.Max(0D, GetSeriesValue(series[s], i)) / max); + double y2 = ToPlotY(GetSeriesValue(series[s], i), min, max, plotTop, plotHeight); double minX = Math.Min(x1, x2); double minY = Math.Min(y1, y2); AddShape(drawing, OfficeShape.Line(x1 - minX, y1 - minY, x2 - minX, y2 - minY), minX, minY, null, color, 1.75); @@ -1357,7 +1357,7 @@ private static void AddLineSeries(OfficeDrawing drawing, ExcelChartSnapshot snap for (int i = 0; i < categories.Count; i++) { double x = plotLeft + step * i - 2D; - double y = plotTop + plotHeight - (plotHeight * Math.Max(0D, GetSeriesValue(series[s], i)) / max) - 2D; + double y = ToPlotY(GetSeriesValue(series[s], i), min, max, plotTop, plotHeight) - 2D; AddShape(drawing, OfficeShape.Ellipse(4D, 4D), x, y, OfficeColor.White, color, 1D); } } @@ -3827,7 +3827,7 @@ private static string FormatCellValue(object? value, ExcelCellStyleSnapshot? sty return null; } - normalized = GetNumberFormatSection(formatCode, number < 0D ? 1 : 0).Trim(); + normalized = GetNumberFormatSection(formatCode, GetNumberFormatSectionIndex(number)).Trim(); if (normalized.Length == 0 || string.Equals(normalized, "General", StringComparison.OrdinalIgnoreCase) || string.Equals(normalized, "@", StringComparison.Ordinal)) { @@ -3893,6 +3893,14 @@ private static string GetNumberFormatSection(string formatCode, int sectionIndex return sections.Length > 0 ? sections[0] : formatCode; } + private static int GetNumberFormatSectionIndex(double number) { + if (number < 0D) { + return 1; + } + + return number == 0D ? 2 : 0; + } + private static bool ShouldWrapNegativeNumber(string formatCode, double value) => value < 0D && formatCode.IndexOf('(') >= 0 && formatCode.IndexOf(')') > formatCode.IndexOf('('); diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index 1f3360b4c..c80c756ff 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -3,6 +3,7 @@ using OfficeIMO.Excel; using OfficeIMO.Excel.Pdf; using System.Globalization; +using System.Reflection; using System.Text; using UglyToad.PdfPig; using Xunit; @@ -1337,6 +1338,8 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { sheet.CellAt(5, 2).SetValue(new DateTime(2026, 1, 15, 0, 30, 5)).SetNumberFormat("mm:ss"); sheet.Cell(6, 1, "Negative"); sheet.CellAt(6, 2).SetValue(-1234).SetNumberFormat("#,##0;(#,##0)"); + sheet.Cell(7, 1, "Zero"); + sheet.CellAt(7, 2).SetValue(0).SetNumberFormat("#,##0;(#,##0);-"); ExcelCellStyleSnapshot currencyStyle = sheet.CellAt(2, 2).GetStyle(); Assert.Equal("\"$\"#,##0.00", currencyStyle.NumberFormatCode); @@ -1363,6 +1366,8 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { Assert.Contains("2026-01-15", text); Assert.Contains("30:05", text); Assert.Contains("(1,234)", text); + Assert.Contains("Zero", text); + Assert.Contains("-", text); Assert.DoesNotContain("01:05", text); Assert.DoesNotContain("1234.5", text); Assert.DoesNotContain("0.257", text); @@ -1712,6 +1717,57 @@ public void SaveAsPdf_ExcelWorkbook_Exports_Area_Chart_Snapshots() { Assert.Contains("0.184 0.435 0.243 RG", rawPdf, StringComparison.Ordinal); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Preserves_Negative_Line_Chart_Values() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfNegativeLineChart.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Charts")) { + ExcelSheet sheet = document.Sheets[0]; + var data = new ExcelChartData( + new[] { "Low", "Zero", "High" }, + new[] { + new ExcelChartSeries("Profit", new[] { -10D, 0D, 10D }, ExcelChartType.Line) + }); + + sheet.AddChart(data, row: 1, column: 5, widthPixels: 360, heightPixels: 220, type: ExcelChartType.Line, title: "Profit Trend"); + + ExcelChart chart = Assert.Single(sheet.Charts); + Assert.True(chart.TryGetSnapshot(out ExcelChartSnapshot snapshot)); + Assert.Equal(ExcelChartType.Line, snapshot.ChartType); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(480, 360), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Profit Trend", text); + Assert.Contains("Profit", text); + + MethodInfo rangeMethod = typeof(ExcelPdfConverterExtensions).GetMethod("GetFiniteSeriesRange", BindingFlags.NonPublic | BindingFlags.Static)!; + object range = rangeMethod.Invoke(null, new object[] { new List { new ExcelChartSeries("Profit", new[] { -10D, 0D, 10D }, ExcelChartType.Line) } })!; + double min = (double)range.GetType().GetField("Item1")!.GetValue(range)!; + double max = (double)range.GetType().GetField("Item2")!.GetValue(range)!; + + MethodInfo plotYMethod = typeof(ExcelPdfConverterExtensions) + .GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Single(method => method.Name == "ToPlotY" && method.GetParameters().Length == 5); + double negativeY = (double)plotYMethod.Invoke(null, new object[] { -10D, min, max, 0D, 100D })!; + double zeroY = (double)plotYMethod.Invoke(null, new object[] { 0D, min, max, 0D, 100D })!; + double positiveY = (double)plotYMethod.Invoke(null, new object[] { 10D, min, max, 0D, 100D })!; + + Assert.Equal(-10D, min); + Assert.Equal(10D, max); + Assert.True(negativeY > zeroY && zeroY > positiveY, "Expected negative, zero, and positive line chart values to map to separate vertical positions."); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Exports_Scatter_Chart_Snapshots() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfScatterCharts.xlsx"); diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs index c78456664..4629a02c2 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs @@ -77,6 +77,37 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Paragraph_Aligned_Images() { Assert.InRange(imageXPositions[2], pageWidth - margin - imageWidth - 1D, pageWidth - margin - imageWidth + 1D); } + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Exports_Loaded_Inline_Paragraph_Images() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeLoadedInlineImage.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeLoadedInlineImage.pdf"); + string imagePath = Path.Combine(_directoryWithImages, "EvotecLogo.png"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + WordParagraph paragraph = document.AddParagraph(); + paragraph.AddText("Before loaded image "); + paragraph.AddImage(imagePath, 48, 48); + paragraph.AddText(" after loaded image"); + document.Save(); + } + + using (WordDocument document = WordDocument.Load(docPath)) { + document.SaveAsPdf(pdfPath, options); + } + + Assert.DoesNotContain(options.Warnings, warning => warning.Code == "NativeBodyImageUnsupported"); + string pdfContent = Encoding.ASCII.GetString(File.ReadAllBytes(pdfPath)); + Assert.Contains("/Subtype /Image", pdfContent); + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Before loaded image", text); + Assert.Contains("after loaded image", text); + } + [Fact] public void SaveAsPdf_OfficeIMOEngine_Maps_Body_PictureControl_To_Image() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativePictureControl.docx"); diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs index 0fed60ca6..52ede14ce 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ParagraphsAndTables.cs @@ -471,6 +471,43 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_TableOfContents_With_Heading_Entri Assert.All(secondTocLinks, link => Assert.Equal("Table of contents: Native TOC second heading", link.Contents)); } + [Fact] + public void SaveAsPdf_OfficeIMOEngine_TableOfContents_Accounts_For_Section_Page_Starts() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeTableOfContentsSections.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeTableOfContentsSections.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddTableOfContent(); + document.AddParagraph("Native TOC first section heading").SetStyle(WordParagraphStyles.Heading1); + document.AddParagraph("Native TOC first section body"); + WordSection secondSection = document.AddSection(); + secondSection.AddParagraph("Native TOC second section heading").SetStyle(WordParagraphStyles.Heading1); + secondSection.AddParagraph("Native TOC second section body"); + + MethodInfo method = typeof(WordPdfConverterExtensions).GetMethod("BuildNativeTableOfContentsEntries", BindingFlags.NonPublic | BindingFlags.Static)!; + object entries = method.Invoke(null, new object[] { + document, + new PdfSaveOptions { IncludePageNumbers = false }, + new Dictionary() + })!; + object secondEntry = ((System.Collections.IEnumerable)entries) + .Cast() + .First(entry => string.Equals((string)entry.GetType().GetProperty("Text")!.GetValue(entry)!, "Native TOC second section heading", StringComparison.Ordinal)); + int secondEntryPage = (int)secondEntry.GetType().GetProperty("PageNumber")!.GetValue(secondEntry)!; + Assert.Equal(2, secondEntryPage); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + Assert.True(pdf.NumberOfPages >= 2, "Expected the second Word section to start on a new PDF page."); + string secondPageText = pdf.GetPage(2).Text; + Assert.Contains("Native TOC second section heading", secondPageText); + } + [Fact] public void SaveAsPdf_OfficeIMOEngine_Creates_Pdf_Outlines_From_Word_Headings() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeadingOutlines.docx"); diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs index 802f6726b..8f495803a 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs @@ -1,3 +1,4 @@ +using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using OfficeIMO.Word; using OfficeIMO.Word.Pdf; @@ -604,6 +605,36 @@ public void SaveAsPdf_OfficeIMOEngine_Records_Warnings_For_Unsupported_HeaderFoo Assert.Contains("Native warning body text", text); } + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Skips_Unsupported_HeaderFooter_Images() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeUnsupportedHeaderImage.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeUnsupportedHeaderImage.pdf"); + string imagePath = Path.Combine(_directoryWithImages, "EvotecLogo.png"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddParagraph().AddImage(imagePath, 32, 32); + document.AddParagraph("Native unsupported header image body"); + document.Save(); + } + + ReplaceFirstHeaderImagePartWithGif(docPath); + + using (WordDocument document = WordDocument.Load(docPath)) { + document.SaveAsPdf(pdfPath, options); + } + + Assert.Contains(options.Warnings, warning => + warning.Code == "NativeHeaderFooterImageUnsupported" && + warning.Source == "default header image"); + Assert.True(File.Exists(pdfPath)); + using PdfDocument pdf = PdfDocument.Open(pdfPath); + Assert.Contains("Native unsupported header image body", pdf.GetPage(1).Text); + } + [Fact] public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_TextBoxes() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterTextBoxes.docx"); @@ -1167,4 +1198,13 @@ private static double FindWordStartX(UglyToad.PdfPig.Content.Page page, string w private static string NormalizePdfText(string text) => string.Join(" ", text.Split(new[] { ' ', '\r', '\n', '\t', '\f' }, StringSplitOptions.RemoveEmptyEntries)); + + private static void ReplaceFirstHeaderImagePartWithGif(string docPath) { + byte[] gifBytes = Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="); + using WordprocessingDocument package = WordprocessingDocument.Open(docPath, true); + HeaderPart headerPart = package.MainDocumentPart!.HeaderParts.First(); + ImagePart imagePart = headerPart.ImageParts.First(); + using var stream = new MemoryStream(gifBytes); + imagePart.FeedData(stream); + } } diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs index 98359fb41..fe16960f1 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -986,12 +986,12 @@ private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, Wor NativeHeaderFooterText? defaultFooter = GetNativeHeaderFooterText(section.Footer?.Default); NativeHeaderFooterText? firstFooter = GetNativeHeaderFooterText(section.Footer?.First); NativeHeaderFooterText? evenFooter = GetNativeHeaderFooterText(section.Footer?.Even); - IReadOnlyList defaultHeaderImages = GetNativeHeaderFooterImages(section.Header?.Default); - IReadOnlyList firstHeaderImages = GetNativeHeaderFooterImages(section.Header?.First); - IReadOnlyList evenHeaderImages = GetNativeHeaderFooterImages(section.Header?.Even); - IReadOnlyList defaultFooterImages = GetNativeHeaderFooterImages(section.Footer?.Default); - IReadOnlyList firstFooterImages = GetNativeHeaderFooterImages(section.Footer?.First); - IReadOnlyList evenFooterImages = GetNativeHeaderFooterImages(section.Footer?.Even); + IReadOnlyList defaultHeaderImages = GetNativeHeaderFooterImages(section.Header?.Default, options, "default header image"); + IReadOnlyList firstHeaderImages = GetNativeHeaderFooterImages(section.Header?.First, options, "first header image"); + IReadOnlyList evenHeaderImages = GetNativeHeaderFooterImages(section.Header?.Even, options, "even header image"); + IReadOnlyList defaultFooterImages = GetNativeHeaderFooterImages(section.Footer?.Default, options, "default footer image"); + IReadOnlyList firstFooterImages = GetNativeHeaderFooterImages(section.Footer?.First, options, "first footer image"); + IReadOnlyList evenFooterImages = GetNativeHeaderFooterImages(section.Footer?.Even, options, "even footer image"); IReadOnlyList defaultHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.Default); IReadOnlyList firstHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.First); IReadOnlyList evenHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.Even); @@ -1552,7 +1552,7 @@ private static void ApplyNativeHeaderFooterPageNumberStyle(PdfCore.PdfPageCompos return parts.HasContent ? parts : null; } - private static IReadOnlyList GetNativeHeaderFooterImages(WordHeaderFooter? headerFooter) { + private static IReadOnlyList GetNativeHeaderFooterImages(WordHeaderFooter? headerFooter, PdfSaveOptions? options, string source) { if (headerFooter == null) { return Array.Empty(); } @@ -1561,10 +1561,10 @@ private static IReadOnlyList GetNativeHeaderFooterImage foreach (WordElement element in headerFooter.Elements) { switch (element) { case WordParagraph paragraph: - AddNativeHeaderFooterParagraphImage(images, paragraph, null); + AddNativeHeaderFooterParagraphImage(images, paragraph, null, options, source); break; case WordTable table: - AddNativeHeaderFooterTableImages(images, table); + AddNativeHeaderFooterTableImages(images, table, options, source); break; } } @@ -1615,12 +1615,12 @@ private static void AddNativeHeaderFooterParagraphText(NativeHeaderFooterText pa } } - private static void AddNativeHeaderFooterTableImages(List images, WordTable table) { + private static void AddNativeHeaderFooterTableImages(List images, WordTable table, PdfSaveOptions? options, string source) { foreach (WordTableRow row in table.Rows) { IReadOnlyList cells = row.Cells; if (cells.Count == 1) { foreach (WordParagraph paragraph in GetNativeCellParagraphs(cells[0])) { - AddNativeHeaderFooterParagraphImage(images, paragraph, null); + AddNativeHeaderFooterParagraphImage(images, paragraph, null, options, source); } continue; @@ -1634,16 +1634,16 @@ private static void AddNativeHeaderFooterTableImages(List images, WordParagraph paragraph, PdfCore.PdfAlign? alignOverride) { + private static void AddNativeHeaderFooterParagraphImage(List images, WordParagraph paragraph, PdfCore.PdfAlign? alignOverride, PdfSaveOptions? options, string source) { PdfCore.PdfAlign align = alignOverride ?? MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false); if (paragraph.Image != null) { - AddNativeHeaderFooterImage(images, paragraph.Image, align); + AddNativeHeaderFooterImage(images, paragraph.Image, align, options, source); } foreach (W.SdtRun pictureControl in GetNativePictureControls(paragraph)) { @@ -1653,12 +1653,24 @@ private static void AddNativeHeaderFooterParagraphImage(List images, WordImage image, PdfCore.PdfAlign align) { + private static void AddNativeHeaderFooterImage(List images, WordImage image, PdfCore.PdfAlign align, PdfSaveOptions? options, string source) { byte[] bytes = ImageEmbedder.GetImageBytes(image); + if (!IsNativePdfSupportedImageBytes(bytes, out string? unsupportedReason)) { + if (options != null) { + AddNativeExportWarning( + options, + "NativeHeaderFooterImageUnsupported", + source, + "Word header/footer image was not exported because the first-party PDF image writer supports JPEG and simple PNG images only. " + unsupportedReason); + } + + return; + } + double width = image.Width.HasValue ? image.Width.Value * 72D / 96D : 144D; double height = image.Height.HasValue ? image.Height.Value * 72D / 96D : 144D; images.Add(new NativeHeaderFooterImage(bytes, width, height, align)); @@ -2473,8 +2485,15 @@ private static IReadOnlyList BuildNativeTableOfConte int headingCount = CountNativeDocumentHeadings(document); int currentPage = 1; double consumedOnPage = 0D; + bool firstSection = true; foreach (WordSection section in document.Sections) { + if (!firstSection) { + currentPage++; + consumedOnPage = 0D; + } + + firstSection = false; PdfCore.PageSize pageSize = GetNativePageSize(section, options); PdfCore.PageMargins margins = GetNativeMargins(section, options); double contentHeight = Math.Max(72D, pageSize.Height - margins.Top - margins.Bottom); @@ -2803,6 +2822,10 @@ private static void RenderNativeParagraph(INativePdfFlow pdf, WordParagraph para } List runs = GetNativeRuns(paragraph); + if (paragraph.Image == null) { + RenderNativeRunImages(pdf, runs, MapNativeParagraphAlign(paragraph.ParagraphAlignment, allowJustify: false), options); + } + string content = paragraph.IsHyperLink && paragraph.Hyperlink != null ? paragraph.Hyperlink.Text : AppendNativeTextWithEquation(paragraph.Text, paragraph); bool hasRenderableRuns = runs.Any(run => !run.IsImage && !string.IsNullOrEmpty(run.Text)); List paragraphFootnoteNumbers = GetNativeParagraphFootnoteNumbers(paragraph, runs, footnoteNumbers, footnoteNumbersById); @@ -3107,6 +3130,14 @@ private static void AddNativeParagraphContent( AddNativeFootnoteReferences(builder, paragraphFootnoteNumbers); } + private static void RenderNativeRunImages(INativePdfFlow pdf, IReadOnlyList runs, PdfCore.PdfAlign align, PdfSaveOptions? options) { + foreach (WordParagraph run in runs) { + if (run.IsImage && run.Image != null) { + RenderNativeImage(pdf, run.Image, align, options, "body paragraph image run"); + } + } + } + private static string? GetNativeSupplementalTextAfterRuns(string content, IReadOnlyList runs) { if (string.IsNullOrEmpty(content)) { return null; From 6c412884d42de00283aed7f51e93882adf21a506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 13:11:47 +0200 Subject: [PATCH 12/18] Address additional PDF export review feedback --- .../ExcelPdfConverterExtensions.cs | 201 +++++++++++++++--- OfficeIMO.Pdf/Rendering/PdfWriter.cs | 4 +- .../Rendering/Writer/PdfWriter.Layout.cs | 28 +-- OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 133 +++++++++++- .../Pdf/Word.SaveAsPdf.PageNumbers.cs | 38 ++++ .../WordPdfConverterExtensions.Native.cs | 4 +- 6 files changed, 362 insertions(+), 46 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index 409ba83cf..44c6bb801 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -302,9 +302,9 @@ private static void ApplyWorksheetHeaderFooter(PdfCore.PdfPageCompose page, Exce header.EvenPagesText(string.Empty); } - AddHeaderImage(header, headerFooter.HeaderLeftImage, options, PdfCore.PdfAlign.Left); - AddHeaderImage(header, headerFooter.HeaderCenterImage, options, PdfCore.PdfAlign.Center); - AddHeaderImage(header, headerFooter.HeaderRightImage, options, PdfCore.PdfAlign.Right); + AddHeaderImage(header, headerFooter.HeaderLeftImage, options, PdfCore.PdfAlign.Left, headerFooter.HeaderLeft, headerFooter.FirstHeaderLeft, headerFooter.EvenHeaderLeft); + AddHeaderImage(header, headerFooter.HeaderCenterImage, options, PdfCore.PdfAlign.Center, headerFooter.HeaderCenter, headerFooter.FirstHeaderCenter, headerFooter.EvenHeaderCenter); + AddHeaderImage(header, headerFooter.HeaderRightImage, options, PdfCore.PdfAlign.Right, headerFooter.HeaderRight, headerFooter.FirstHeaderRight, headerFooter.EvenHeaderRight); }); } @@ -337,9 +337,9 @@ private static void ApplyWorksheetHeaderFooter(PdfCore.PdfPageCompose page, Exce footer.EvenPagesText(string.Empty); } - AddFooterImage(footer, headerFooter.FooterLeftImage, options, PdfCore.PdfAlign.Left); - AddFooterImage(footer, headerFooter.FooterCenterImage, options, PdfCore.PdfAlign.Center); - AddFooterImage(footer, headerFooter.FooterRightImage, options, PdfCore.PdfAlign.Right); + AddFooterImage(footer, headerFooter.FooterLeftImage, options, PdfCore.PdfAlign.Left, headerFooter.FooterLeft, headerFooter.FirstFooterLeft, headerFooter.EvenFooterLeft); + AddFooterImage(footer, headerFooter.FooterCenterImage, options, PdfCore.PdfAlign.Center, headerFooter.FooterCenter, headerFooter.FirstFooterCenter, headerFooter.EvenFooterCenter); + AddFooterImage(footer, headerFooter.FooterRightImage, options, PdfCore.PdfAlign.Right, headerFooter.FooterRight, headerFooter.FirstFooterRight, headerFooter.EvenFooterRight); }); } } @@ -358,18 +358,49 @@ private static bool HasAnyFooterImage(ExcelSheet.HeaderFooterSnapshot headerFoot || IsPdfSupportedImage(headerFooter.FooterRightImage)); } - private static void AddHeaderImage(PdfCore.PdfHeaderCompose header, ExcelSheet.HeaderFooterImageSnapshot? image, ExcelPdfSaveOptions options, PdfCore.PdfAlign align) { + private static void AddHeaderImage(PdfCore.PdfHeaderCompose header, ExcelSheet.HeaderFooterImageSnapshot? image, ExcelPdfSaveOptions options, PdfCore.PdfAlign align, string defaultZone, string firstZone, string evenZone) { if (options.UseWorksheetHeaderFooterImages && IsPdfSupportedImage(image)) { - header.Image(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + bool defaultHasImage = HasPicturePlaceholder(defaultZone); + bool firstHasImage = HasPicturePlaceholder(firstZone); + bool evenHasImage = HasPicturePlaceholder(evenZone); + bool hasSpecificPlaceholder = defaultHasImage || firstHasImage || evenHasImage; + if (!hasSpecificPlaceholder || defaultHasImage) { + header.Image(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + } + + if (firstHasImage) { + header.FirstPageImage(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + } + + if (evenHasImage) { + header.EvenPagesImage(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + } } } - private static void AddFooterImage(PdfCore.PdfFooterCompose footer, ExcelSheet.HeaderFooterImageSnapshot? image, ExcelPdfSaveOptions options, PdfCore.PdfAlign align) { + private static void AddFooterImage(PdfCore.PdfFooterCompose footer, ExcelSheet.HeaderFooterImageSnapshot? image, ExcelPdfSaveOptions options, PdfCore.PdfAlign align, string defaultZone, string firstZone, string evenZone) { if (options.UseWorksheetHeaderFooterImages && IsPdfSupportedImage(image)) { - footer.Image(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + bool defaultHasImage = HasPicturePlaceholder(defaultZone); + bool firstHasImage = HasPicturePlaceholder(firstZone); + bool evenHasImage = HasPicturePlaceholder(evenZone); + bool hasSpecificPlaceholder = defaultHasImage || firstHasImage || evenHasImage; + if (!hasSpecificPlaceholder || defaultHasImage) { + footer.Image(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + } + + if (firstHasImage) { + footer.FirstPageImage(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + } + + if (evenHasImage) { + footer.EvenPagesImage(image!.Bytes, image.WidthPoints, image.HeightPoints, align); + } } } + private static bool HasPicturePlaceholder(string? text) => + text?.IndexOf("&G", StringComparison.Ordinal) >= 0; + private static bool IsPdfSupportedImage(ExcelSheet.HeaderFooterImageSnapshot? image) { return image != null && image.Bytes.Length > 0 @@ -1292,9 +1323,14 @@ private static void AddAreaSeries(OfficeDrawing drawing, ExcelChartSnapshot snap bool stacked = IsStackedAreaChart(snapshot.ChartType) || IsPercentStackedAreaChart(snapshot.ChartType); bool percentStacked = IsPercentStackedAreaChart(snapshot.ChartType); - double max = percentStacked ? 1D : stacked ? GetPositiveStackedMax(series, categories.Count) : GetPositiveMax(series); + (double min, double max) = percentStacked + ? (0D, 1D) + : stacked + ? GetStackedSeriesRange(series, categories.Count) + : GetFiniteSeriesRange(series); double step = plotWidth / (categories.Count - 1); - var cumulative = new double[categories.Count]; + var positiveCumulative = new double[categories.Count]; + var negativeCumulative = new double[categories.Count]; for (int s = 0; s < series.Count; s++) { OfficeColor color = GetChartSeriesColor(s); @@ -1302,8 +1338,11 @@ private static void AddAreaSeries(OfficeDrawing drawing, ExcelChartSnapshot snap var bottomPoints = new List(categories.Count); for (int i = 0; i < categories.Count; i++) { - double rawValue = Math.Max(0D, GetSeriesValue(series[s], i)); - double baseline = stacked ? cumulative[i] : 0D; + double value = GetSeriesValue(series[s], i); + double rawValue = percentStacked ? Math.Max(0D, value) : value; + double baseline = stacked + ? (rawValue >= 0D ? positiveCumulative[i] : negativeCumulative[i]) + : 0D; double topValue = baseline + rawValue; if (percentStacked) { @@ -1313,8 +1352,8 @@ private static void AddAreaSeries(OfficeDrawing drawing, ExcelChartSnapshot snap } double x = plotLeft + step * i; - topPoints.Add(new OfficePoint(x, ToPlotY(topValue, max, plotTop, plotHeight))); - bottomPoints.Add(new OfficePoint(x, ToPlotY(baseline, max, plotTop, plotHeight))); + topPoints.Add(new OfficePoint(x, ToPlotY(topValue, min, max, plotTop, plotHeight))); + bottomPoints.Add(new OfficePoint(x, ToPlotY(baseline, min, max, plotTop, plotHeight))); } var areaPoints = new List(topPoints.Count + bottomPoints.Count); @@ -1328,7 +1367,12 @@ private static void AddAreaSeries(OfficeDrawing drawing, ExcelChartSnapshot snap if (stacked) { for (int i = 0; i < categories.Count; i++) { - cumulative[i] += Math.Max(0D, GetSeriesValue(series[s], i)); + double value = percentStacked ? Math.Max(0D, GetSeriesValue(series[s], i)) : GetSeriesValue(series[s], i); + if (value >= 0D) { + positiveCumulative[i] += value; + } else { + negativeCumulative[i] += value; + } } } } @@ -3814,6 +3858,20 @@ private static string FormatCellValue(object? value, ExcelCellStyleSnapshot? sty return null; } + if (ContainsElapsedToken(normalized)) { + double elapsedNumber; + if (value is DateTime elapsedDate) { + elapsedNumber = elapsedDate.ToOADate(); + } else if (!TryGetDouble(value, out elapsedNumber)) { + elapsedNumber = double.NaN; + } + + if (!double.IsNaN(elapsedNumber) && + TryFormatElapsedDuration(elapsedNumber, normalized, out string? elapsedText)) { + return elapsedText; + } + } + if (value is DateTime dateValue || style.IsDateLike) { DateTime date = value is DateTime directDate ? directDate @@ -3846,16 +3904,71 @@ private static string FormatCellValue(object? value, ExcelCellStyleSnapshot? sty return numeric + "%"; } - string? prefix = ExtractQuotedLiteralPrefix(normalized); bool useGrouping = normalized.IndexOf(',') >= 0; int decimalPlaces = CountDecimalPlaces(normalized); string numberFormat = (useGrouping ? "N" : "F") + decimalPlaces.ToString(CultureInfo.InvariantCulture); bool wrapNumber = ShouldWrapNegativeNumber(normalized, number); double displayNumber = wrapNumber ? Math.Abs(number) : number; - string numericValue = (prefix ?? string.Empty) + displayNumber.ToString(numberFormat, CultureInfo.InvariantCulture); + string numericValue = ApplyQuotedLiterals(normalized, displayNumber.ToString(numberFormat, CultureInfo.InvariantCulture)); return wrapNumber ? "(" + numericValue + ")" : numericValue; } + private static bool TryFormatElapsedDuration(double value, string formatCode, out string? text) { + text = null; + if (!ContainsElapsedToken(formatCode)) { + return false; + } + + bool negative = value < 0D; + TimeSpan duration = TimeSpan.FromDays(Math.Abs(value)); + string result = formatCode; + bool replaced = false; + if (TryReplaceElapsedToken(ref result, "[hh]", (long)Math.Floor(duration.TotalHours), 2) || + TryReplaceElapsedToken(ref result, "[h]", (long)Math.Floor(duration.TotalHours), 1)) { + replaced = true; + } else if (TryReplaceElapsedToken(ref result, "[mm]", (long)Math.Floor(duration.TotalMinutes), 2) || + TryReplaceElapsedToken(ref result, "[m]", (long)Math.Floor(duration.TotalMinutes), 1)) { + replaced = true; + } else if (TryReplaceElapsedToken(ref result, "[ss]", (long)Math.Floor(duration.TotalSeconds), 2) || + TryReplaceElapsedToken(ref result, "[s]", (long)Math.Floor(duration.TotalSeconds), 1)) { + replaced = true; + } + + if (!replaced) { + return false; + } + + result = ReplaceIgnoreCase(result, "hh", duration.Hours.ToString("D2", CultureInfo.InvariantCulture)); + result = ReplaceIgnoreCase(result, "h", duration.Hours.ToString(CultureInfo.InvariantCulture)); + result = ReplaceIgnoreCase(result, "mm", duration.Minutes.ToString("D2", CultureInfo.InvariantCulture)); + result = ReplaceIgnoreCase(result, "m", duration.Minutes.ToString(CultureInfo.InvariantCulture)); + result = ReplaceIgnoreCase(result, "ss", duration.Seconds.ToString("D2", CultureInfo.InvariantCulture)); + result = ReplaceIgnoreCase(result, "s", duration.Seconds.ToString(CultureInfo.InvariantCulture)); + text = negative ? "-" + result : result; + return true; + } + + private static bool ContainsElapsedToken(string formatCode) => + formatCode.IndexOf("[h]", StringComparison.OrdinalIgnoreCase) >= 0 || + formatCode.IndexOf("[hh]", StringComparison.OrdinalIgnoreCase) >= 0 || + formatCode.IndexOf("[m]", StringComparison.OrdinalIgnoreCase) >= 0 || + formatCode.IndexOf("[mm]", StringComparison.OrdinalIgnoreCase) >= 0 || + formatCode.IndexOf("[s]", StringComparison.OrdinalIgnoreCase) >= 0 || + formatCode.IndexOf("[ss]", StringComparison.OrdinalIgnoreCase) >= 0; + + private static bool TryReplaceElapsedToken(ref string formatCode, string token, long value, int minimumDigits) { + int index = formatCode.IndexOf(token, StringComparison.OrdinalIgnoreCase); + if (index < 0) { + return false; + } + + string replacement = minimumDigits > 1 + ? value.ToString("D" + minimumDigits.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture) + : value.ToString(CultureInfo.InvariantCulture); + formatCode = formatCode.Substring(0, index) + replacement + formatCode.Substring(index + token.Length); + return true; + } + private static bool TryGetDouble(object value, out double number) { switch (value) { case double doubleValue: @@ -3924,19 +4037,51 @@ private static int CountDecimalPlaces(string formatCode) { return count; } - private static string? ExtractQuotedLiteralPrefix(string formatCode) { - int quoteStart = formatCode.IndexOf('"'); - if (quoteStart < 0) { - return null; + private static string ApplyQuotedLiterals(string formatCode, string numericValue) { + string prefix = string.Empty; + string suffix = string.Empty; + int index = 0; + while (index < formatCode.Length) { + int quoteStart = formatCode.IndexOf('"', index); + if (quoteStart < 0) { + break; + } + + int quoteEnd = formatCode.IndexOf('"', quoteStart + 1); + if (quoteEnd <= quoteStart + 1) { + break; + } + + string literal = formatCode.Substring(quoteStart + 1, quoteEnd - quoteStart - 1); + bool hasPlaceholderBefore = HasNumberPlaceholder(formatCode, 0, quoteStart); + if (hasPlaceholderBefore) { + if (quoteStart > 0 && char.IsWhiteSpace(formatCode[quoteStart - 1])) { + suffix += " "; + } + + suffix += literal; + } else { + prefix += literal; + if (quoteEnd + 1 < formatCode.Length && char.IsWhiteSpace(formatCode[quoteEnd + 1])) { + prefix += " "; + } + } + + index = quoteEnd + 1; } - int quoteEnd = formatCode.IndexOf('"', quoteStart + 1); - if (quoteEnd <= quoteStart + 1) { - return null; + return prefix + numericValue + suffix; + } + + private static bool HasNumberPlaceholder(string formatCode, int start, int end) { + for (int i = start; i < end && i < formatCode.Length; i++) { + char ch = formatCode[i]; + if (ch == '0' || ch == '#' || ch == '?') { + return true; + } } - string literal = formatCode.Substring(quoteStart + 1, quoteEnd - quoteStart - 1); - return literal.Length == 0 ? null : literal; + return false; } private static string ToDotNetDateTimeFormat(string excelFormat) { diff --git a/OfficeIMO.Pdf/Rendering/PdfWriter.cs b/OfficeIMO.Pdf/Rendering/PdfWriter.cs index 63a4d9bc5..05800a5b4 100644 --- a/OfficeIMO.Pdf/Rendering/PdfWriter.cs +++ b/OfficeIMO.Pdf/Rendering/PdfWriter.cs @@ -117,7 +117,7 @@ string EnsurePageFontResource(PdfStandardFont font, string preferredAlias) { AddHeaderFooterImages(page, pageOpts, headerFooterVariantPageNumber); string contentStr = pageBackgroundContent + headerFooterShapeContent; if (pageOpts.HasHeaderTextContentForPage(headerFooterVariantPageNumber)) { - string headerContent = BuildHeader(pageOpts, headerFooterVariantPageNumber, headerFooterPageNumber, headerFooterTotalPages, pageOpts.HeaderFont, headerFontAlias!); + string headerContent = BuildHeader(pageOpts, headerFooterVariantPageNumber, headerFooterPageNumber, headerFooterTotalPages, totalPages, pageOpts.HeaderFont, headerFontAlias!); contentStr += headerContent; } contentStr += page.Content; @@ -165,7 +165,7 @@ string EnsurePageFontResource(PdfStandardFont font, string preferredAlias) { contentStr += sbImgs.ToString(); } if (pageOpts.HasFooterTextContentForPage(headerFooterVariantPageNumber)) { - string footer = BuildFooter(pageOpts, headerFooterVariantPageNumber, headerFooterPageNumber, headerFooterTotalPages, pageOpts.FooterFont, footerFontAlias!); + string footer = BuildFooter(pageOpts, headerFooterVariantPageNumber, headerFooterPageNumber, headerFooterTotalPages, totalPages, pageOpts.FooterFont, footerFontAlias!); contentStr += footer; } int contentId = AddStreamObject(objects, Encoding.ASCII.GetBytes(contentStr)); diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs index d9eafc18a..4aee1b952 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs @@ -1399,16 +1399,16 @@ private static bool TryApplyWidowControl(PdfParagraphStyle? style, int totalLine return false; } - private static string BuildFooter(PdfOptions opts, int variantPage, int page, int pages, PdfStandardFont footerFont, string footerFontResource) { + private static string BuildFooter(PdfOptions opts, int variantPage, int page, int pages, int documentPages, PdfStandardFont footerFont, string footerFontResource) { string text; var footerSegments = opts.GetFooterSegmentsForPage(variantPage); var footerZones = opts.GetFooterZonesForPage(variantPage); if (HasPageTextZones(footerZones)) { - return BuildPageTextZones(opts, footerZones, page, pages, footerFont, footerFontResource, opts.FooterFontSize, opts.FooterTextColor, opts.FooterOffsetY, isHeader: false); + return BuildPageTextZones(opts, footerZones, page, pages, documentPages, footerFont, footerFontResource, opts.FooterFontSize, opts.FooterTextColor, opts.FooterOffsetY, isHeader: false); } else if (footerSegments != null && footerSegments.Count > 0) { text = BuildPageTextFromSegments(footerSegments, page, pages, opts.PageNumberStyle); } else { - text = FormatPageText(opts.GetFooterFormatForPage(variantPage), page, pages, opts.PageNumberStyle); + text = FormatPageText(opts.GetFooterFormatForPage(variantPage), page, pages, documentPages, opts.PageNumberStyle); } double width = opts.PageWidth - opts.MarginLeft - opts.MarginRight; double textWidth = EstimateSimpleTextWidth(text, footerFont, opts.FooterFontSize); @@ -1432,16 +1432,16 @@ private static string BuildFooter(PdfOptions opts, int variantPage, int page, in return sb.ToString(); } - private static string BuildHeader(PdfOptions opts, int variantPage, int page, int pages, PdfStandardFont headerFont, string headerFontResource) { + private static string BuildHeader(PdfOptions opts, int variantPage, int page, int pages, int documentPages, PdfStandardFont headerFont, string headerFontResource) { string text; var headerSegments = opts.GetHeaderSegmentsForPage(variantPage); var headerZones = opts.GetHeaderZonesForPage(variantPage); if (HasPageTextZones(headerZones)) { - return BuildPageTextZones(opts, headerZones, page, pages, headerFont, headerFontResource, opts.HeaderFontSize, opts.HeaderTextColor, opts.HeaderOffsetY, isHeader: true); + return BuildPageTextZones(opts, headerZones, page, pages, documentPages, headerFont, headerFontResource, opts.HeaderFontSize, opts.HeaderTextColor, opts.HeaderOffsetY, isHeader: true); } else if (headerSegments != null && headerSegments.Count > 0) { text = BuildPageTextFromSegments(headerSegments, page, pages, opts.PageNumberStyle); } else { - text = FormatPageText(opts.GetHeaderFormatForPage(variantPage), page, pages, opts.PageNumberStyle); + text = FormatPageText(opts.GetHeaderFormatForPage(variantPage), page, pages, documentPages, opts.PageNumberStyle); } double width = opts.PageWidth - opts.MarginLeft - opts.MarginRight; @@ -1477,6 +1477,7 @@ private static string BuildPageTextZones( (string? Left, string? Center, string? Right) zones, int page, int pages, + int documentPages, PdfStandardFont font, string fontResource, double fontSize, @@ -1486,7 +1487,7 @@ private static string BuildPageTextZones( double width = opts.PageWidth - opts.MarginLeft - opts.MarginRight; double y = isHeader ? opts.PageHeight - opts.MarginTop + offset : opts.MarginBottom - offset; var sb = new StringBuilder(); - var zoneLayouts = BuildPageTextZoneLayouts(opts, zones, page, pages, font, fontSize, isHeader); + var zoneLayouts = BuildPageTextZoneLayouts(opts, zones, page, pages, documentPages, font, fontSize, isHeader); foreach (var zone in zoneLayouts) { AppendPageText(sb, zone.Text, fontResource, fontSize, color, zone.X, y); } @@ -1499,6 +1500,7 @@ private static string BuildPageTextZones( (string? Left, string? Center, string? Right) zones, int page, int pages, + int documentPages, PdfStandardFont font, double fontSize, bool isHeader) { @@ -1507,19 +1509,19 @@ private static string BuildPageTextZones( var layouts = new System.Collections.Generic.List<(string Name, string Text, double X, double Width)>(); if (!string.IsNullOrEmpty(zones.Left)) { - string text = FormatPageText(zones.Left!, page, pages, opts.PageNumberStyle); + string text = FormatPageText(zones.Left!, page, pages, documentPages, opts.PageNumberStyle); double textWidth = EstimateSimpleTextWidth(text, font, fontSize); layouts.Add(("left", text, contentLeft, textWidth)); } if (!string.IsNullOrEmpty(zones.Center)) { - string text = FormatPageText(zones.Center!, page, pages, opts.PageNumberStyle); + string text = FormatPageText(zones.Center!, page, pages, documentPages, opts.PageNumberStyle); double textWidth = EstimateSimpleTextWidth(text, font, fontSize); layouts.Add(("center", text, contentLeft + ((contentWidth - textWidth) / 2), textWidth)); } if (!string.IsNullOrEmpty(zones.Right)) { - string text = FormatPageText(zones.Right!, page, pages, opts.PageNumberStyle); + string text = FormatPageText(zones.Right!, page, pages, documentPages, opts.PageNumberStyle); double textWidth = EstimateSimpleTextWidth(text, font, fontSize); layouts.Add(("right", text, contentLeft + contentWidth - textWidth, textWidth)); } @@ -1581,12 +1583,14 @@ private static string BuildPageTextFromSegments(System.Collections.Generic.IRead return sb.ToString(); } - private static string FormatPageText(string format, int page, int pages, PdfPageNumberStyle style) { + private static string FormatPageText(string format, int page, int pages, int documentPages, PdfPageNumberStyle style) { string pageText = FormatPageNumber(page, style); string pagesText = FormatPageNumber(pages, style); + string documentPagesText = FormatPageNumber(documentPages, style); return format .Replace("{page}", pageText) - .Replace("{pages}", pagesText); + .Replace("{pages}", pagesText) + .Replace("{documentpages}", documentPagesText); } private static string FormatPageNumber(int number, PdfPageNumberStyle style) { diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index c80c756ff..2b9c10a97 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -854,6 +854,63 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_HeaderFooter_Images() { Assert.Empty(PdfCore.PdfImageExtractor.ExtractImages(disabledBytes)); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Routes_HeaderFooter_Images_To_First_And_Even_Variants() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterVariantImages.xlsx"); + + byte[] imageBytes = CreateMinimalRgbPng(); + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Ledger")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Entry"); + for (int row = 2; row <= 90; row++) { + sheet.Cell(row, 1, "Ledger row " + row.ToString(CultureInfo.InvariantCulture)); + } + + sheet.SetHeaderFooter(headerCenter: "Odd Header &A", footerCenter: "Odd Footer &P"); + sheet.SetHeaderImage(HeaderFooterPosition.Center, imageBytes, "image/png", widthPoints: 24, heightPoints: 16); + + HeaderFooter headerFooter = sheet.WorksheetPart.Worksheet.GetFirstChild()!; + headerFooter.DifferentFirst = true; + headerFooter.DifferentOddEven = true; + headerFooter.OddHeader = new OddHeader("&COdd Header &A"); + headerFooter.FirstHeader = new FirstHeader("&C&GFirst Header &A"); + headerFooter.EvenHeader = new EvenHeader("&C&GEven Header &A"); + sheet.WorksheetPart.Worksheet.Save(); + + ExcelSheet.HeaderFooterSnapshot snapshot = sheet.GetHeaderFooter(); + Assert.Equal("Odd Header &A", snapshot.HeaderCenter); + Assert.Equal("&GFirst Header &A", snapshot.FirstHeaderCenter); + Assert.Equal("&GEven Header &A", snapshot.EvenHeaderCenter); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(48) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.True(pdf.NumberOfPages >= 3); + Assert.Contains("First Header Ledger", pdf.GetPage(1).Text); + Assert.Contains("Even Header Ledger", pdf.GetPage(2).Text); + Assert.Contains("Odd Header Ledger", pdf.GetPage(3).Text); + + int[] imagePages = PdfCore.PdfImageExtractor + .ExtractImages(bytes) + .Select(image => image.PageNumber) + .Distinct() + .OrderBy(page => page) + .ToArray(); + Assert.Contains(1, imagePages); + Assert.Contains(2, imagePages); + Assert.DoesNotContain(3, imagePages); + Assert.All(imagePages, page => Assert.True(page == 1 || page % 2 == 0, "Expected header image only on first and even pages.")); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Can_Disable_Worksheet_HeaderFooter_Text_Zones() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooterDisabled.xlsx"); @@ -1354,13 +1411,13 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { IncludeSheetHeadings = false, HeaderRowCount = 1, - PageSize = new PdfCore.PageSize(420, 260), + PageSize = new PdfCore.PageSize(420, 360), Margins = PdfCore.PageMargins.Uniform(24) }); } using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); - string text = pdf.GetPage(1).Text; + string text = string.Concat(Enumerable.Range(1, pdf.NumberOfPages).Select(page => pdf.GetPage(page).Text)); Assert.Contains("$1,234.50", text); Assert.Contains("25.7%", text); Assert.Contains("2026-01-15", text); @@ -1374,6 +1431,40 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Common_Number_Formats() { Assert.DoesNotContain("-1,234", text); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Maps_Elapsed_Time_And_Quoted_Number_Literals() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfElapsedAndQuotedFormats.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Formats")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Kind"); + sheet.Cell(1, 2, "Value"); + sheet.Cell(2, 1, "Elapsed"); + sheet.CellAt(2, 2).SetValue(1.5).SetNumberFormat("[h]:mm"); + sheet.Cell(3, 1, "Units"); + sheet.CellAt(3, 2).SetValue(12).SetNumberFormat("0 \"kg\""); + + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 220), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Elapsed", text); + Assert.Contains("36:00", text); + Assert.Contains("Units", text); + Assert.Contains("12 kg", text); + Assert.DoesNotContain("12:00", text); + Assert.DoesNotContain("kg12", text); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Uses_Worksheet_Column_Widths_And_Print_Scale() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfColumnWidths.xlsx"); @@ -1768,6 +1859,44 @@ public void SaveAsPdf_ExcelWorkbook_Preserves_Negative_Line_Chart_Values() { Assert.True(negativeY > zeroY && zeroY > positiveY, "Expected negative, zero, and positive line chart values to map to separate vertical positions."); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Exports_Negative_Area_Chart_Values() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfNegativeAreaChart.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Charts")) { + ExcelSheet sheet = document.Sheets[0]; + var data = new ExcelChartData( + new[] { "Q1", "Q2", "Q3" }, + new[] { + new ExcelChartSeries("Delta", new[] { -6D, 0D, 9D }, ExcelChartType.Area) + }); + + sheet.AddChart(data, row: 1, column: 5, widthPixels: 360, heightPixels: 220, type: ExcelChartType.Area, title: "Delta Area"); + ExcelChart chart = Assert.Single(sheet.Charts); + Assert.True(chart.TryGetSnapshot(out ExcelChartSnapshot snapshot)); + Assert.Equal(ExcelChartType.Area, snapshot.ChartType); + + document.Save(false); + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(480, 360), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("Delta Area", text); + Assert.Contains("Delta", text); + + MethodInfo rangeMethod = typeof(ExcelPdfConverterExtensions).GetMethod("GetFiniteSeriesRange", BindingFlags.NonPublic | BindingFlags.Static)!; + object range = rangeMethod.Invoke(null, new object[] { new List { new ExcelChartSeries("Delta", new[] { -6D, 0D, 9D }, ExcelChartType.Area) } })!; + Assert.Equal(-6D, (double)range.GetType().GetField("Item1")!.GetValue(range)!); + Assert.Equal(9D, (double)range.GetType().GetField("Item2")!.GetValue(range)!); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Exports_Scatter_Chart_Snapshots() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfScatterCharts.xlsx"); diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs index 382247dca..f948592b4 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs @@ -90,6 +90,44 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_Word_Section_PageNumbering() { } } + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Keeps_NumPages_As_Document_Total_When_Section_Restarts() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeNumPagesDocumentTotal.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeNumPagesDocumentTotal.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + RequireSectionFooter(document, 0, HeaderFooterValues.Default) + .AddParagraph("First field footer ") + .AddPageNumber(includeTotalPages: true, separator: " / "); + document.AddParagraph("First section first page"); + document.AddPageBreak(); + document.AddParagraph("First section second page"); + + WordSection secondSection = document.AddSection(); + secondSection.AddPageNumbering(1, NumberFormatValues.Decimal); + RequireSectionFooter(document, 1, HeaderFooterValues.Default) + .AddParagraph("Restart field footer ") + .AddPageNumber(includeTotalPages: true, separator: " / "); + secondSection.AddParagraph("Restarted section first page"); + document.AddPageBreak(); + secondSection.AddParagraph("Restarted section second page"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + Assert.Equal(4, pdf.NumberOfPages); + string page3Text = NormalizeNativePageNumberText(pdf.GetPage(3).Text); + string page4Text = NormalizeNativePageNumberText(pdf.GetPage(4).Text); + Assert.Contains("Restartfieldfooter1/4", page3Text, StringComparison.Ordinal); + Assert.Contains("Restartfieldfooter2/4", page4Text, StringComparison.Ordinal); + Assert.DoesNotContain("Restartfieldfooter1/2", page3Text, StringComparison.Ordinal); + } + [Fact] public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_PageFields_To_PageTokens() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterPageFields.docx"); diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs index fe16960f1..cbd87df65 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -2073,7 +2073,7 @@ private static bool TryGetNativeHeaderFooterFieldToken(WordParagraph paragraph, } if (field?.FieldType == WordFieldType.NumPages) { - token = "{pages}"; + token = "{documentpages}"; style = MapNativePageNumberFieldStyle(field.Field); return true; } @@ -2102,7 +2102,7 @@ private static bool TryGetNativeHeaderFooterFieldToken(string fieldCode, out str } if (string.Equals(fieldType, "NUMPAGES", StringComparison.OrdinalIgnoreCase)) { - token = "{pages}"; + token = "{documentpages}"; style = MapNativePageNumberFieldStyle(trimmed); return true; } From 0e8a2870fb489c8af75cd11b3ef08886c23e51d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 13:21:15 +0200 Subject: [PATCH 13/18] Address remaining PDF export review feedback --- .../ExcelPdfConverterExtensions.cs | 19 +++++-- OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 31 ++++++++++- .../Pdf/Word.SaveAsPdf.Footnotes.cs | 26 +++++++++ .../Pdf/Word.SaveAsPdf.Sections.cs | 54 +++++++++++++++++++ .../WordPdfConverterExtensions.Native.cs | 42 +++++++-------- 5 files changed, 146 insertions(+), 26 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index 44c6bb801..25a163f95 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -145,7 +145,9 @@ private static IReadOnlyList BuildWorksheetExportPlans(E } ISet? exportedCellReferences = CreateExportedCellReferenceSet(exportData.CellReferences, exportedRows); - bool filterMediaToExportedCells = HasWorksheetPrintArea(workbookSheet, options) || options.MaxRowsPerSheet.HasValue; + bool filterMediaToExportedCells = HasWorksheetPrintArea(workbookSheet, options) || + options.MaxRowsPerSheet.HasValue || + (options.RespectWorksheetHiddenRowsAndColumns && HasHiddenRowsOrColumns(workbookSheet)); IReadOnlyList images = FilterImagesByExportedCells(ReadWorksheetImages(workbookSheet, options, sheetName), exportedCellReferences, filterMediaToExportedCells); IReadOnlyList charts = FilterChartsByExportedCells(ReadWorksheetCharts(workbookSheet, options, sheetName), exportedCellReferences, filterMediaToExportedCells); if (!hasTable && images.Count == 0 && charts.Count == 0) { @@ -928,8 +930,8 @@ private static IReadOnlyList GetSheetNames(ExcelDocumentReader reader, E throw new ArgumentException("Sheet names cannot contain null, empty, or whitespace values.", nameof(options)); } - reader.GetSheet(name); - names.Add(name); + ExcelSheetReader sheet = reader.GetSheet(name); + names.Add(sheet.Name); } return names; @@ -1944,7 +1946,7 @@ private static bool IsDoughnutChart(ExcelChartType type) { private static ExcelSheet? GetWorkbookSheet(ExcelDocument document, string sheetName) { foreach (ExcelSheet sheet in document.Sheets) { - if (string.Equals(sheet.Name, sheetName, StringComparison.Ordinal)) { + if (string.Equals(sheet.Name, sheetName, StringComparison.OrdinalIgnoreCase)) { return sheet; } } @@ -1952,6 +1954,15 @@ private static bool IsDoughnutChart(ExcelChartType type) { return null; } + private static bool HasHiddenRowsOrColumns(ExcelSheet? workbookSheet) { + if (workbookSheet == null) { + return false; + } + + return workbookSheet.GetRowDefinitions().Any(row => row.Hidden) || + workbookSheet.GetColumnDefinitions().Any(column => column.Hidden); + } + private static string GetExportRange(ExcelSheetReader sheet, ExcelSheet? workbookSheet, ExcelPdfSaveOptions options) { string? printArea = GetWorksheetPrintArea(workbookSheet, options); if (!string.IsNullOrWhiteSpace(printArea)) { diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index 2b9c10a97..ce86101b0 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -48,18 +48,20 @@ public void SaveAsPdf_ExcelWorkbook_Respects_Selected_Sheets() { ExcelSheet summary = document.AddWorkSheet("Summary"); summary.Cell(1, 1, "Metric"); summary.Cell(2, 1, "SelectedValue"); + summary.SetHeaderFooter(headerCenter: "Selected Header &A"); ExcelSheet internalSheet = document.AddWorkSheet("Internal"); internalSheet.Cell(1, 1, "HiddenValue"); document.Save(false); bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { - SheetNames = new[] { "Summary" } + SheetNames = new[] { "summary" } }); } using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); string text = pdf.GetPage(1).Text; Assert.Contains("Summary", text); + Assert.Contains("Selected Header Summary", text); Assert.Contains("SelectedValue", text); Assert.DoesNotContain("HiddenValue", text); } @@ -106,6 +108,33 @@ public void SaveAsPdf_ExcelWorkbook_Exports_Worksheet_Images() { Assert.Equal(1, extractedImage.Height); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Filters_Images_Anchored_To_Hidden_Cells() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHiddenCellImages.xlsx"); + + byte[] imageBytes = CreateMinimalRgbPng(); + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Images")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "VisibleMarker"); + sheet.Cell(2, 1, "HiddenImageMarker"); + sheet.AddImage(2, 1, imageBytes, "image/png", widthPixels: 24, heightPixels: 16, name: "Hidden Logo", altText: "Hidden logo"); + sheet.SetRowHidden(2, true); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + RespectWorksheetHiddenRowsAndColumns = true + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + string text = pdf.GetPage(1).Text; + Assert.Contains("VisibleMarker", text); + Assert.DoesNotContain("HiddenImageMarker", text); + Assert.Empty(PdfCore.PdfImageExtractor.ExtractImages(bytes)); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Warns_And_Skips_Invalid_Worksheet_Image_Bytes() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfInvalidImageBytes.xlsx"); diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs index aff336c60..027301178 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs @@ -81,5 +81,31 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Endnote_Markers_And_Text() { Assert.Contains("Native after notes", allText); } } + + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Keeps_Footnote_Numbering_Continuous_Across_Sections() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionFootnotes.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionFootnotes.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("First section note").AddFootNote("First section footnote"); + WordSection secondSection = document.AddSection(); + secondSection.AddParagraph("Second section note").AddFootNote("Second section footnote"); + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + using (var pdf = PdfDocument.Open(pdfPath)) { + string allText = string.Concat(pdf.GetPages().Select(p => p.Text)); + string normalizedText = Regex.Replace(allText, @"\s+", " "); + Assert.Contains("First section note1", allText); + Assert.Contains("Second section note2", allText); + Assert.Contains("1 First section footnote", normalizedText); + Assert.Contains("2 Second section footnote", normalizedText); + Assert.DoesNotContain("Second section note1", allText); + } + } } } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs index 8f495803a..3035a39e9 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Sections.cs @@ -532,6 +532,60 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_First_And_Even_HeaderFooter_Varian } } + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Ignores_First_And_Even_HeaderFooter_Parts_When_Section_Flags_Are_Off() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeStaleHeaderFooterVariants.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeStaleHeaderFooterVariants.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + document.DifferentFirstPage = true; + document.DifferentOddAndEvenPages = true; + + RequireSectionHeader(document, 0, HeaderFooterValues.Default).AddParagraph("Native Default Header"); + RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph("Native Default Footer"); + RequireSectionHeader(document, 0, HeaderFooterValues.First).AddParagraph("Native Stale First Header"); + RequireSectionFooter(document, 0, HeaderFooterValues.First).AddParagraph("Native Stale First Footer"); + RequireSectionHeader(document, 0, HeaderFooterValues.Even).AddParagraph("Native Stale Even Header"); + RequireSectionFooter(document, 0, HeaderFooterValues.Even).AddParagraph("Native Stale Even Footer"); + + for (int i = 0; i < 240; i++) { + document.AddParagraph($"Native stale variant paragraph {i}"); + } + + document.Save(); + } + + using (WordprocessingDocument package = WordprocessingDocument.Open(docPath, true)) { + Settings? settings = package.MainDocumentPart!.DocumentSettingsPart!.Settings; + settings?.RemoveAllChildren(); + foreach (TitlePage titlePage in package.MainDocumentPart.Document.Body!.Descendants().ToList()) { + titlePage.Remove(); + } + + package.Save(); + } + + using (WordDocument document = WordDocument.Load(docPath)) { + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + using (PdfDocument pdf = PdfDocument.Open(pdfPath)) { + Assert.True(pdf.NumberOfPages >= 2); + string allText = string.Concat(pdf.GetPages().Select(page => page.Text)); + Assert.Contains("Native Default Header", pdf.GetPage(1).Text); + Assert.Contains("Native Default Footer", pdf.GetPage(1).Text); + Assert.Contains("Native Default Header", pdf.GetPage(2).Text); + Assert.Contains("Native Default Footer", pdf.GetPage(2).Text); + Assert.DoesNotContain("Native Stale First Header", allText); + Assert.DoesNotContain("Native Stale First Footer", allText); + Assert.DoesNotContain("Native Stale Even Header", allText); + Assert.DoesNotContain("Native Stale Even Footer", allText); + } + } + [Fact] public void SaveAsPdf_OfficeIMOEngine_Preserves_Blank_First_And_Even_HeaderFooter_Variants() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativeBlankHeaderFooterVariants.docx"); diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs index cbd87df65..3dba01217 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -100,9 +100,10 @@ private static PdfCore.PdfDoc CreateOfficeIMOPdfDocument(WordDocument document, Dictionary listIndices = DocumentTraversal.BuildListIndices(document); Dictionary headingDestinations = BuildNativeHeadingDestinations(document); IReadOnlyList tableOfContentsEntries = BuildNativeTableOfContentsEntries(document, options, headingDestinations); + var footnoteNumbersById = new Dictionary(); foreach (WordSection section in document.Sections) { IReadOnlyList elements = CollapseNativeParagraphElements(section.Elements); - List footnotes = CollectNativeFootnotes(elements, out Dictionary footnoteNumbersById); + List footnotes = CollectNativeFootnotes(elements, footnoteNumbersById); pdf.Section(page => { page.Size(GetNativePageSize(section, options)); page.Margin(GetNativeMargins(section, options)); @@ -981,28 +982,28 @@ private static void ConfigureNativeHeaderFooter(PdfCore.PdfPageCompose page, Wor RecordNativeHeaderFooterDiagnostics(section.Footer?.Even, options, "even footer"); NativeHeaderFooterText? defaultHeader = GetNativeHeaderFooterText(section.Header?.Default); - NativeHeaderFooterText? firstHeader = GetNativeHeaderFooterText(section.Header?.First); - NativeHeaderFooterText? evenHeader = GetNativeHeaderFooterText(section.Header?.Even); + NativeHeaderFooterText? firstHeader = section.DifferentFirstPage ? GetNativeHeaderFooterText(section.Header?.First) : null; + NativeHeaderFooterText? evenHeader = section.DifferentOddAndEvenPages ? GetNativeHeaderFooterText(section.Header?.Even) : null; NativeHeaderFooterText? defaultFooter = GetNativeHeaderFooterText(section.Footer?.Default); - NativeHeaderFooterText? firstFooter = GetNativeHeaderFooterText(section.Footer?.First); - NativeHeaderFooterText? evenFooter = GetNativeHeaderFooterText(section.Footer?.Even); + NativeHeaderFooterText? firstFooter = section.DifferentFirstPage ? GetNativeHeaderFooterText(section.Footer?.First) : null; + NativeHeaderFooterText? evenFooter = section.DifferentOddAndEvenPages ? GetNativeHeaderFooterText(section.Footer?.Even) : null; IReadOnlyList defaultHeaderImages = GetNativeHeaderFooterImages(section.Header?.Default, options, "default header image"); - IReadOnlyList firstHeaderImages = GetNativeHeaderFooterImages(section.Header?.First, options, "first header image"); - IReadOnlyList evenHeaderImages = GetNativeHeaderFooterImages(section.Header?.Even, options, "even header image"); + IReadOnlyList firstHeaderImages = section.DifferentFirstPage ? GetNativeHeaderFooterImages(section.Header?.First, options, "first header image") : Array.Empty(); + IReadOnlyList evenHeaderImages = section.DifferentOddAndEvenPages ? GetNativeHeaderFooterImages(section.Header?.Even, options, "even header image") : Array.Empty(); IReadOnlyList defaultFooterImages = GetNativeHeaderFooterImages(section.Footer?.Default, options, "default footer image"); - IReadOnlyList firstFooterImages = GetNativeHeaderFooterImages(section.Footer?.First, options, "first footer image"); - IReadOnlyList evenFooterImages = GetNativeHeaderFooterImages(section.Footer?.Even, options, "even footer image"); + IReadOnlyList firstFooterImages = section.DifferentFirstPage ? GetNativeHeaderFooterImages(section.Footer?.First, options, "first footer image") : Array.Empty(); + IReadOnlyList evenFooterImages = section.DifferentOddAndEvenPages ? GetNativeHeaderFooterImages(section.Footer?.Even, options, "even footer image") : Array.Empty(); IReadOnlyList defaultHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.Default); - IReadOnlyList firstHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.First); - IReadOnlyList evenHeaderShapes = GetNativeHeaderFooterShapes(section.Header?.Even); + IReadOnlyList firstHeaderShapes = section.DifferentFirstPage ? GetNativeHeaderFooterShapes(section.Header?.First) : Array.Empty(); + IReadOnlyList evenHeaderShapes = section.DifferentOddAndEvenPages ? GetNativeHeaderFooterShapes(section.Header?.Even) : Array.Empty(); IReadOnlyList defaultFooterShapes = GetNativeHeaderFooterShapes(section.Footer?.Default); - IReadOnlyList firstFooterShapes = GetNativeHeaderFooterShapes(section.Footer?.First); - IReadOnlyList evenFooterShapes = GetNativeHeaderFooterShapes(section.Footer?.Even); + IReadOnlyList firstFooterShapes = section.DifferentFirstPage ? GetNativeHeaderFooterShapes(section.Footer?.First) : Array.Empty(); + IReadOnlyList evenFooterShapes = section.DifferentOddAndEvenPages ? GetNativeHeaderFooterShapes(section.Footer?.Even) : Array.Empty(); ApplyNativeHeaderFooterPageNumberStyle(page, defaultHeader, firstHeader, evenHeader, defaultFooter, firstFooter, evenFooter); - bool hasFirstHeaderVariant = section.DifferentFirstPage || firstHeader != null || firstHeaderImages.Count > 0 || firstHeaderShapes.Count > 0; - bool hasEvenHeaderVariant = section.DifferentOddAndEvenPages || evenHeader != null || evenHeaderImages.Count > 0 || evenHeaderShapes.Count > 0; - bool hasFirstFooterVariant = section.DifferentFirstPage || firstFooter != null || firstFooterImages.Count > 0 || firstFooterShapes.Count > 0; - bool hasEvenFooterVariant = section.DifferentOddAndEvenPages || evenFooter != null || evenFooterImages.Count > 0 || evenFooterShapes.Count > 0; + bool hasFirstHeaderVariant = section.DifferentFirstPage; + bool hasEvenHeaderVariant = section.DifferentOddAndEvenPages; + bool hasFirstFooterVariant = section.DifferentFirstPage; + bool hasEvenFooterVariant = section.DifferentOddAndEvenPages; if (defaultHeader != null || hasFirstHeaderVariant || hasEvenHeaderVariant || defaultHeaderImages.Count > 0 || firstHeaderImages.Count > 0 || evenHeaderImages.Count > 0 || defaultHeaderShapes.Count > 0 || firstHeaderShapes.Count > 0 || evenHeaderShapes.Count > 0) { @@ -4933,9 +4934,8 @@ private static string GetNativeCellParagraphText(WordParagraph paragraph) { return AppendNativeTextWithEquation(text, paragraph); } - private static List CollectNativeFootnotes(IReadOnlyList elements, out Dictionary footnoteNumbersById) { + private static List CollectNativeFootnotes(IReadOnlyList elements, Dictionary footnoteNumbersById) { var footnotes = new List(); - footnoteNumbersById = new Dictionary(); foreach (WordElement element in elements) { CollectNativeFootnotes(element, footnotes, footnoteNumbersById); } @@ -5003,7 +5003,7 @@ private static void AddNativeFootnote(WordFootNote footNote, List f return; } - int number = footnotes.Count + 1; + int number = footnoteNumbersById.Count + 1; footnoteNumbersById[key] = number; footnotes.Add(new PdfFootnote { Number = number, @@ -5022,7 +5022,7 @@ private static void AddNativeEndnote(WordEndNote endNote, List foot return; } - int number = footnotes.Count + 1; + int number = footnoteNumbersById.Count + 1; footnoteNumbersById[key] = number; footnotes.Add(new PdfFootnote { Number = number, From a124ea8121ecea585c1530f92778ed958dae1ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 13:44:30 +0200 Subject: [PATCH 14/18] Align PDF package build and docs metadata --- .github/codeql/codeql-config.yml | 1 + Build/project.build.json | 1 + Docs/officeimo.pdf.support-matrix.md | 2 +- OfficeIMO.Excel.Pdf/README.md | 2 +- OfficeIMO.Pdf/README.md | 6 +- OfficeIMO.Word.Pdf/README.md | 4 +- README.md | 10 +++- .../getting-started/installation/index.md | 57 ++++++++++++++++++- .../getting-started/platform-support/index.md | 8 ++- Website/content/docs/index.md | 9 ++- Website/content/pages/third-party.md | 13 +++-- Website/content/products/excel.md | 7 +++ Website/data/downloads_catalog.json | 14 +++++ Website/data/faq.json | 4 +- Website/data/product-details/excel.json | 7 ++- Website/data/product-details/powerpoint.json | 2 +- Website/data/product-details/word.json | 2 +- Website/data/release_placements.json | 6 ++ Website/pipeline.json | 37 ++++++++++++ 19 files changed, 164 insertions(+), 28 deletions(-) diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index f78b3897a..bbc23a4a5 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -5,6 +5,7 @@ paths: - OfficeIMO.Epub - OfficeIMO.Excel - OfficeIMO.Excel.GoogleSheets + - OfficeIMO.Excel.Pdf - OfficeIMO.GoogleWorkspace - OfficeIMO.Markdown - OfficeIMO.Markdown.Html diff --git a/Build/project.build.json b/Build/project.build.json index 2c46bc7ea..4ba5cf053 100644 --- a/Build/project.build.json +++ b/Build/project.build.json @@ -7,6 +7,7 @@ "OfficeIMO.Drawing": "1.0.X", "OfficeIMO.Epub": "0.0.X", "OfficeIMO.Excel": "0.6.X", + "OfficeIMO.Excel.Pdf": "1.0.X", "OfficeIMO.Markdown": "0.6.X", "OfficeIMO.Markdown.Html": "0.1.X", "OfficeIMO.MarkdownRenderer": "0.2.X", diff --git a/Docs/officeimo.pdf.support-matrix.md b/Docs/officeimo.pdf.support-matrix.md index 57e3c412b..e8c1d8c99 100644 --- a/Docs/officeimo.pdf.support-matrix.md +++ b/Docs/officeimo.pdf.support-matrix.md @@ -61,7 +61,7 @@ Status values: | Forms | Flatten forms | Partial | `PdfFormFiller.FlattenFields(...)` and `FillAndFlattenFields(...)` can paint simple text-widget appearances, simple choice-widget text appearances with `/Opt` display text when available for scalar or array selected values, and simple button-widget normal appearance states into page content, generating minimal button appearances when needed, remove those page annotations, and remove the AcroForm tree for parser-supported PDFs from bytes, paths, or streams; helpers return bytes from path inputs and write path inputs to paths or caller-owned output streams; rich/custom appearances, JavaScript actions, and safe complex form preservation remain roadmap work | | Security | Encryption/signatures/redaction | Partial | `PdfInspector.Probe` reports encryption/signature/form/outline/catalog-view-setting/page-label/catalog-name-tree/named-destination/open-action/viewer-preference/tagged-structure/XMP-metadata/catalog-URI/output-intent/embedded-file/optional-content/active-content markers and `PdfInspector.Preflight` turns unsupported markers into read/rewrite decisions with diagnostics plus structured `PdfReadBlockerKind` and `PdfRewriteBlockerKind` entries; encrypted PDFs fail with a clear unsupported diagnostic for parser-supported read/manipulation flows; signed PDFs, form PDFs, complex outline PDFs, complex page-label PDFs, unsupported catalog name-tree PDFs, malformed or unsupported named-destination name-tree PDFs, complex open-action dictionary PDFs, complex viewer-preference PDFs, complex XMP metadata PDFs, complex catalog URI PDFs, tagged PDFs, complex output-intent PDFs, complex embedded-file/associated-file PDFs, complex optional-content PDFs, and active-content PDFs are blocked for rewrite-style manipulation. Simple direct catalog view settings, simple outlines including simple GoTo action outline entries, simple direct page labels, direct named destinations, simple destination name trees including leaf `/Kids`, destination-array open actions, simple GoTo open-action dictionaries, simple viewer preferences, simple catalog XMP metadata streams, simple catalog URI base dictionaries, simple output intents, simple embedded-file attachment trees, simple associated-file arrays, and simple optional-content metadata are preserved. Creation, validation, redaction, and encrypted reading remain planned | | Convert | Word to PDF without QuestPDF | Partial | `OfficeIMO.Word.Pdf` now defaults to the first-party engine; `PdfSaveOptions.PageSize` and `Margins` provide a QuestPDF-free page setup surface using first-party `OfficeIMO.Pdf` geometry types, with explicit `PageSize` geometry preserved unless `PdfSaveOptions.Orientation` is set; the current native path maps basic Word sections, page setup, Word document background color, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, page breaks, headings including linked headings, paragraphs/runs with common Word/PDF font family requests mapped to standard Helvetica, Times, and Courier PDF families, isolated run color, font-size, superscript/subscript baseline, justified paragraph alignment, text-wrapping breaks, and highlight/background state, paragraph spacing/indents, simple tab stops with leaders/alignment, keep-with-next/keep-lines/widow-control flags, simple shaded and uniform/non-uniform bordered paragraphs, Word horizontal lines and paragraph top/bottom border rules, simple level-0 bullet/decimal lists with rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, footnote/endnote markers, simple tables with supported Word table style presets, rich text runs inside table cells, default and per-cell table margins, table cell spacing, table-level borders, uniform/non-uniform, double, and diagonal cell borders, uniform and non-uniform row heights, row-level break policies, preferred DXA table widths that fit into narrower native PDF column frames, explicit autofit-to-contents tables, cell fills, left/center/right table placement, uniform column and non-uniform per-cell horizontal/vertical alignment, simple merged cells, separated first-row visual table styling and repeated leading table header rows, and linked cells including linked merged cells, paragraph-aligned images, simple VML shapes plus the DrawingML preset flow shapes exposed by `WordShape`, simple body text boxes rendered through first-party panel paragraphs, simple body, table-cell, header, and footer picture content controls rendered as first-party PDF images, simple body repeating-section text items rendered as ordinary first-party PDF paragraphs, simple table-cell repeating-section text items rendered as first-party rich table-cell text, simple header/footer repeating-section text items rendered as first-party zone text, simple header/footer text boxes with extractable text routed through first-party zones, simple inline body/table/header/footer text content controls, simple body-level and table-cell Word check boxes as inspectable PDF AcroForm check boxes with readback and Poppler raster-baseline coverage in the native Word report fixture, simple body-level and table-cell Word dropdown, combo box, and date picker content controls as inspectable PDF AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple default/first/even header and footer text/images/shapes with left/center/right paragraph alignment, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, and simple header/footer table-cell text/images/shapes mapped to first-party zones, simple footnote/endnote markers with end-of-section note text, metadata, and page-number footer settings including Word section page-number starts/styles into `OfficeIMO.Pdf`; the Poppler lane now includes a daily-layout Word fixture covering TOC, margins, page background color, columns including inline column breaks, separator lines, fonts, colors, lists, links, images, headers/footers, and a table inside the column flow. `PdfSaveOptions.Warnings` records unsupported native header/footer visual content such as shapes without supported geometry, text boxes without extractable text, SmartArt, equations, unsupported content controls, and embedded documents, plus unsupported body SmartArt, equations, unsupported header/footer content controls, embedded documents, and unhandled body elements that are not yet faithfully mapped. The old QuestPDF/SkiaSharp engine path has been removed from `OfficeIMO.Word.Pdf`; remaining work is fidelity and coverage in the first-party exporter | -| Convert | Excel to PDF | Partial | `OfficeIMO.Excel.Pdf` provides the first Excel-to-PDF package surface. The exporter maps selected or all visible workbook worksheets into first-party `OfficeIMO.Pdf` headings and tables, honors worksheet print areas, worksheet orientation, worksheet margins, hidden workbook worksheet filtering for default all-sheet exports, hidden worksheet rows and columns, repeated print-title rows through the PDF table header model, manual worksheet row and column page breaks as explicit PDF page breaks while preserving repeated header/title rows across split table chunks, simple worksheet header/footer text zones with first-page and even-page text variants plus page-number, page-count, sheet-name, date, time, workbook file-name, and workbook path tokens, simple line-level header/footer font family/style, font size, and RGB text color when representable as one first-party PDF header/footer line style, and supported header/footer images, worksheet merged cells through PDF table column/row spans, supported worksheet drawing images anchored into exported PDF table cells when the anchor cell is exported and otherwise emitted as PDF flow images in anchor order, supported column/bar/line/area/scatter/radar/pie/doughnut worksheet chart families as first-party vector drawing snapshots when chart data can be read, and common number formats plus basic explicit cell font emphasis, font color, fill color, two-color conditional color-scale fills, conditional data bars, conditional icon-set indicators, horizontal/vertical alignment, simple cell borders including dashed, dotted, dash-dot, double, and diagonal strokes, external cell hyperlinks, internal workbook links as sheet-level PDF named destinations, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, and fit-to-width table sizing through first-party table/rich-text/image primitives; supports explicit page size/margin options through reusable PDF geometry types; can return bytes or write to paths/streams; and now has a Poppler raster baseline for a daily two-sheet workbook covering worksheet header/footer text/images, orientation/margins, merged title cells, fills/borders, number formats, explicit row/column sizing, hidden row/column filtering, anchored worksheet images, chart snapshots, and internal/external links. `ExcelPdfSaveOptions.Warnings` records unsupported or simplified export features such as mixed or rich per-run worksheet header/footer formatting, unsupported or unreadable worksheet/header/footer images, unsupported or unreadable chart snapshots, and row truncation from `MaxRowsPerSheet`. Richer worksheet header/footer formatting beyond the current line-level style mapping, cell-specific internal workbook-link destinations, fit-to-height and automatic multi-page pagination/scaling, richer worksheet image placement fidelity beyond exported table-cell anchors, richer chart fidelity beyond initial column/bar/line/area/scatter/radar/pie/doughnut snapshots, richer cell style fidelity such as additional conditional formats and locale-specific formats, richer merged-cell edge cases, and broader unsupported-feature diagnostics remain roadmap work | +| Convert | Excel to PDF | Partial | `OfficeIMO.Excel.Pdf` provides the first Excel-to-PDF package surface. The exporter maps selected or all visible workbook worksheets into first-party `OfficeIMO.Pdf` headings and tables, honors worksheet print areas, worksheet orientation, worksheet margins, hidden workbook worksheet filtering for default all-sheet exports, hidden worksheet rows and columns, repeated print-title rows through the PDF table header model, manual worksheet row and column page breaks as explicit PDF page breaks while preserving repeated header/title rows across split table chunks, simple worksheet header/footer text zones with first-page and even-page text variants plus page-number, page-count, sheet-name, date, time, workbook file-name, and workbook path tokens, simple line-level header/footer font family/style, font size, and RGB text color when representable as one first-party PDF header/footer line style, and supported header/footer images, worksheet merged cells through PDF table column/row spans, supported worksheet drawing images anchored into exported PDF table cells when the anchor cell is exported and otherwise emitted as PDF flow images in anchor order, supported column/bar/line/area/scatter/radar/pie/doughnut worksheet chart families as first-party vector drawing snapshots when chart data can be read, and common number formats plus basic explicit cell font emphasis, font color, fill color, two-color conditional color-scale fills, conditional data bars, conditional icon-set indicators, horizontal/vertical alignment, simple cell borders including dashed, dotted, dash-dot, double, and diagonal strokes, external cell hyperlinks, internal workbook links only when their target cell is exported as an exact PDF named destination, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, and fit-to-width table sizing through first-party table/rich-text/image primitives; supports explicit page size/margin options through reusable PDF geometry types; can return bytes or write to paths/streams; and now has a Poppler raster baseline for a daily two-sheet workbook covering worksheet header/footer text/images, orientation/margins, merged title cells, fills/borders, number formats, explicit row/column sizing, hidden row/column filtering, anchored worksheet images, chart snapshots, and internal/external links. `ExcelPdfSaveOptions.Warnings` records unsupported or simplified export features such as mixed or rich per-run worksheet header/footer formatting, unsupported or unreadable worksheet/header/footer images, unsupported or unreadable chart snapshots, and row truncation from `MaxRowsPerSheet`. Richer worksheet header/footer formatting beyond the current line-level style mapping, fit-to-height and automatic multi-page pagination/scaling, richer worksheet image placement fidelity beyond exported table-cell anchors, richer chart fidelity beyond initial column/bar/line/area/scatter/radar/pie/doughnut snapshots, richer cell style fidelity such as additional conditional formats and locale-specific formats, richer merged-cell edge cases, and broader unsupported-feature diagnostics remain roadmap work | | Convert | PowerPoint to PDF | Planned | Later phases after the PDF layout engine matures | Word-to-PDF equation note: simple OMML equations with extractable math text are mapped as static first-party PDF text in body paragraphs, table cells, headers, and footers. Equation warnings in the convert row refer to equations without extractable text. diff --git a/OfficeIMO.Excel.Pdf/README.md b/OfficeIMO.Excel.Pdf/README.md index 9c6d99278..a148d4d75 100644 --- a/OfficeIMO.Excel.Pdf/README.md +++ b/OfficeIMO.Excel.Pdf/README.md @@ -16,7 +16,7 @@ Current scope: - Manual worksheet row and column page breaks mapped to explicit PDF page breaks between exported table chunks, preserving repeated header/title rows, with `ExcelPdfSaveOptions.UseWorksheetPageBreaks` available to disable that behavior. - Simple worksheet header/footer text zones, first-page and even-page text variants, and supported header/footer images, including page number, total page count, sheet-name, date, time, workbook file-name, and workbook path tokens. Simple line-level header/footer font family/style, font size, and RGB text color are mapped when the styled text can be represented by one first-party PDF header/footer line style. - Sheet names as PDF headings. -- Cell display values rendered as PDF table cells, with common number formats, basic cell font emphasis, font color, fill color, two-color conditional color-scale fills, conditional data bars as proportional in-cell PDF table overlays, conditional icon-set indicators as first-party table-cell vector icons, horizontal/vertical alignment, simple cell borders including dashed, dotted, dash-dot, double, and diagonal strokes, external cell hyperlinks, internal workbook links mapped to exact exported-cell PDF named destinations with sheet-level fallback, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, fit-to-width table sizing against effective page margins, and worksheet merged cells mapped through first-party rich table cells, per-cell table fills, per-cell table data bars, per-cell table icons, per-cell table alignment/border/padding overrides, relative table column widths, table max-width caps, row minimum heights, visible-row/column filtering, cell-owned URI and named-destination annotations, and table row/column spans. +- Cell display values rendered as PDF table cells, with common number formats, basic cell font emphasis, font color, fill color, two-color conditional color-scale fills, conditional data bars as proportional in-cell PDF table overlays, conditional icon-set indicators as first-party table-cell vector icons, horizontal/vertical alignment, simple cell borders including dashed, dotted, dash-dot, double, and diagonal strokes, external cell hyperlinks, internal workbook links mapped only when their target cell is exported as an exact PDF named destination, explicit worksheet column widths, explicit worksheet row heights, manual worksheet print scale, fit-to-width table sizing against effective page margins, and worksheet merged cells mapped through first-party rich table cells, per-cell table fills, per-cell table data bars, per-cell table icons, per-cell table alignment/border/padding overrides, relative table column widths, table max-width caps, row minimum heights, visible-row/column filtering, cell-owned URI and named-destination annotations, and table row/column spans. - Supported worksheet drawing images anchored into exported PDF table cells when the anchor cell is exported and otherwise emitted as first-party PDF flow images in worksheet anchor order. - Supported worksheet column, bar, line, area, scatter, radar, pie, and doughnut chart families exported as first-party vector drawing snapshots when the chart data can be read from the workbook. - `ExcelPdfSaveOptions.Warnings` reports workbook features that are skipped or simplified during export, including mixed or rich per-run header/footer formatting, unsupported header/footer fields, unsupported or unreadable worksheet/header/footer images, unsupported or unreadable chart snapshots, and row truncation when `MaxRowsPerSheet` is used. diff --git a/OfficeIMO.Pdf/README.md b/OfficeIMO.Pdf/README.md index a586896f1..5414b1224 100644 --- a/OfficeIMO.Pdf/README.md +++ b/OfficeIMO.Pdf/README.md @@ -17,8 +17,8 @@ Goals - Keep the public creation API Word-like and primitive-based; polished invoice/report/statement outputs belong in samples, visual fixtures, or wrappers, not as special engine concepts. - Keep rasterizers and visual comparison tools in tests/dev tooling, not in the runtime package. -Relationship to OfficeIMO.Word.Pdf ----------------------------------- +Relationship to OfficeIMO.Word.Pdf and OfficeIMO.Excel.Pdf +---------------------------------------------------------- `OfficeIMO.Word.Pdf` now defaults to the first-party `OfficeIMO.Pdf` engine for Word-to-PDF export. @@ -26,6 +26,8 @@ The default first-party path maps basic Word sections, page setup with explicit Simple Word OMML equations with extractable math text are exported as static first-party PDF text in body paragraphs, table cells, headers, and footers; equations without extractable text still produce `PdfSaveOptions.Warnings`. +`OfficeIMO.Excel.Pdf` uses the same first-party engine for workbook-to-PDF export. It keeps workbook reading in `OfficeIMO.Excel`, maps worksheets into PDF headings, tables, images, charts, links, headers, footers, margins, and orientation, and leaves PDF layout and writing in `OfficeIMO.Pdf`. + The strategic target is for `OfficeIMO.Pdf` to become good enough that Word, Excel, and PowerPoint exporters can render through the first-party engine without bringing QuestPDF, SkiaSharp, iText, or other runtime PDF dependencies into the core PDF package. Quick Start diff --git a/OfficeIMO.Word.Pdf/README.md b/OfficeIMO.Word.Pdf/README.md index 6430a2795..e178dc305 100644 --- a/OfficeIMO.Word.Pdf/README.md +++ b/OfficeIMO.Word.Pdf/README.md @@ -1,6 +1,6 @@ -# OfficeIMO.Pdf — PDF Helpers +# OfficeIMO.Word.Pdf - Word to PDF Export -Utilities bridging OfficeIMO outputs with PDF workflows. +First-party Word document to PDF export using `OfficeIMO.Pdf`. `OfficeIMO.Word.Pdf` defaults to the first-party `OfficeIMO.Pdf` exporter. Use `PdfSaveOptions.PageSize` and `Margins` for page setup; they use `OfficeIMO.Pdf.PageSize` and `OfficeIMO.Pdf.PageMargins` in PDF points, and `PageSize` is preserved as exact page geometry unless `PdfSaveOptions.Orientation` is also set. The native path maps Word document background color, common Word/PDF font-family requests to standard Helvetica, Times, and Courier PDF families for document defaults and paragraph/run text, scoped Word paragraph/run font sizes, superscript/subscript baseline, highlights, justified paragraphs, text-wrapping breaks, Word section columns with explicit and inline paragraph column breaks, explicit unequal section column widths, Word section column separator lines, and heading/keep-with-next-aware automatic distribution for multi-column sections without explicit breaks, rich list-item runs, list-item bookmarks, links/bookmarks with tooltip metadata, generated table-of-contents entries with internal links to heading destinations, heading-based PDF outlines, Word horizontal lines, paragraph top/bottom border rules, simple non-uniform paragraph side borders, uniform/non-uniform, double, and diagonal table cell borders, default and per-cell table margins, uniform/non-uniform table row heights, row-level table break policies, separated first-row visual table styling and contiguous leading repeated table header rows, uniform column and non-uniform per-cell table alignment, fixed-width Word tables fit into narrower native PDF column frames, footnote/endnote markers with simple note text, simple text boxes, simple VML shapes plus the DrawingML preset shapes exposed by `WordShape`, simple body, table-cell, header, and footer picture content controls, simple body, table-cell, header, and footer repeating-section text items, simple inline body/table/header/footer text content controls, simple body and table-cell Word check boxes as first-party PDF AcroForm check boxes, simple body-level and table-cell Word dropdown, combo box, and date picker controls as first-party PDF AcroForm choice/text fields, simple header/footer Word check boxes, dropdowns, combo boxes, and date pickers as static first-party zone text, simple body/table-cell/header/footer OMML equation text as static first-party text, Word section page-number starts/styles, Word PAGE/NUMPAGES header/footer fields and their simple numeric format switches, simple header/footer paragraph alignment, simple header/footer paragraph and table-cell images/shapes, simple header/footer table-cell zones, and rich table-cell runs into first-party rich text primitives, records `PdfSaveOptions.Warnings` for unsupported native header/footer and body content that cannot be mapped faithfully yet, and has initial Poppler raster baselines for Word-origin report, daily-layout, and table-cell picture-control fixtures. diff --git a/README.md b/README.md index 851b0f4bf..55f1e437f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Most packages are MIT licensed. `OfficeIMO.Visio` is a special case: the project - [OfficeIMO.Word.Html](OfficeIMO.Word.Html/README.md) - [OfficeIMO.Word.Markdown](OfficeIMO.Word.Markdown/README.md) - [OfficeIMO.Word.Pdf](OfficeIMO.Word.Pdf/README.md) +- [OfficeIMO.Excel.Pdf](OfficeIMO.Excel.Pdf/README.md) - [OfficeIMO.Markdown.Html](OfficeIMO.Markdown.Html/README.md) ### Markdown and rendering packages @@ -106,6 +107,7 @@ Most packages are MIT licensed. `OfficeIMO.Visio` is a special case: the project ### Excel family - `OfficeIMO.Excel`: workbook, worksheet, table, range, style, and reporting helpers +- `OfficeIMO.Excel.Pdf`: Excel workbook to PDF export through the first-party `OfficeIMO.Pdf` engine - `OfficeIMO.Excel.GoogleSheets`: Excel to Google Sheets planning, batch compilation, and export helpers - `OfficeIMO.Excel.Benchmarks`: benchmark harness for Excel package behavior @@ -170,7 +172,7 @@ Important exceptions: - For trimming-sensitive workloads, prefer typed overloads and explicit selectors. - `OfficeIMO.Markdown`, `OfficeIMO.CSV`, `OfficeIMO.Drawing`, `OfficeIMO.Pdf`, `OfficeIMO.Zip`, and `OfficeIMO.Epub` are the lightest dependency shapes. - Open XML-heavy packages should be tested against the exact publish options and document features your application uses. -- `OfficeIMO.Word.Pdf` should be treated separately because PDF layout fidelity and host fonts still need scenario validation. +- `OfficeIMO.Word.Pdf` and `OfficeIMO.Excel.Pdf` should be treated separately because PDF layout fidelity and host fonts still need scenario validation. ## Dependencies at a glance @@ -213,6 +215,7 @@ flowchart TB ```mermaid flowchart TB Excel["OfficeIMO.Excel"] + ExcelPdf["OfficeIMO.Excel.Pdf"] PowerPoint["OfficeIMO.PowerPoint"] Visio["OfficeIMO.Visio"] Drawing["OfficeIMO.Drawing"] @@ -225,6 +228,8 @@ flowchart TB Excel --> Drawing Excel --> OpenXml + ExcelPdf --> Excel + ExcelPdf --> Pdf PowerPoint --> OpenXml Visio --> Drawing Visio --> Packaging @@ -343,6 +348,7 @@ flowchart TB - Word to Markdown or Markdown to Word: add `OfficeIMO.Word`, `OfficeIMO.Word.Markdown`, and the Markdown packages it references - Word to PDF: add `OfficeIMO.Word` and `OfficeIMO.Word.Pdf` - Creating Excel workbooks and reports: add `OfficeIMO.Excel` +- Excel to PDF: add `OfficeIMO.Excel` and `OfficeIMO.Excel.Pdf` - Creating PowerPoint decks: add `OfficeIMO.PowerPoint` - Creating Visio diagrams: add `OfficeIMO.Visio` - Working directly with Markdown: add `OfficeIMO.Markdown` @@ -363,7 +369,7 @@ flowchart TB - `DocumentFormat.OpenXml`: `[3.5.1, 4.0.0)` in the Open XML packages that reference it - `OfficeIMO.Drawing`: first-party color and image metadata helpers - `AngleSharp` / `AngleSharp.Css`: HTML parsing and CSS conversion layers -- `OfficeIMO.Pdf`: first-party Word-to-PDF conversion engine and dependency-light PDF primitives +- `OfficeIMO.Pdf`: first-party Word/Excel-to-PDF conversion engine and dependency-light PDF primitives - `System.Text.Json`: reader, renderer, and Google Workspace helper surfaces on legacy target frameworks - `Microsoft.Web.WebView2`: WPF Markdown renderer host - `System.IO.Packaging`: Visio package handling diff --git a/Website/content/docs/getting-started/installation/index.md b/Website/content/docs/getting-started/installation/index.md index 58474d62e..551dc4afd 100644 --- a/Website/content/docs/getting-started/installation/index.md +++ b/Website/content/docs/getting-started/installation/index.md @@ -130,6 +130,54 @@ dotnet add package OfficeIMO.Word.Markdown ``` +### OfficeIMO.Word.Pdf + +Word-to-PDF conversion built on the first-party OfficeIMO.Pdf engine. + +**.NET CLI** + +```bash +dotnet add package OfficeIMO.Word.Pdf +``` + +**PackageReference** + +```xml + +``` + +### OfficeIMO.Excel.Pdf + +Excel workbook-to-PDF conversion built on the first-party OfficeIMO.Pdf engine. + +**.NET CLI** + +```bash +dotnet add package OfficeIMO.Excel.Pdf +``` + +**PackageReference** + +```xml + +``` + +### OfficeIMO.Pdf + +Direct PDF generation and PDF utility primitives without runtime package dependencies. + +**.NET CLI** + +```bash +dotnet add package OfficeIMO.Pdf +``` + +**PackageReference** + +```xml + +``` + ## PSWriteOffice (PowerShell Module) PSWriteOffice wraps OfficeIMO for use from PowerShell. Install it from the PowerShell Gallery: @@ -169,12 +217,17 @@ Get-Module PSWriteOffice OfficeIMO.Word and OfficeIMO.Excel depend on: -- **DocumentFormat.OpenXml** (>= 3.3.0, < 4.0.0) -- The Microsoft Open XML SDK. +- **DocumentFormat.OpenXml** (>= 3.5.1, < 4.0.0) -- The Microsoft Open XML SDK. - **OfficeIMO.Drawing** -- First-party color and image metadata helpers used by the document packages. +OfficeIMO.Excel also uses compatibility helper packages on older targets: + +- **Microsoft.Bcl.AsyncInterfaces** -- Async interface compatibility for `netstandard2.0` and `net472`. +- **System.Text.Json** -- JSON support for `netstandard2.0` and `net472`. + OfficeIMO.Word.Html additionally depends on: - **AngleSharp** (1.3.0) -- HTML parsing and DOM manipulation. - **AngleSharp.Css** (1.0.0-beta.157) -- CSS parsing for style mapping. -OfficeIMO.Markdown and OfficeIMO.CSV have **no external dependencies** beyond the .NET runtime. +OfficeIMO.Pdf, OfficeIMO.Markdown, and OfficeIMO.CSV have **no external dependencies** beyond the .NET runtime. diff --git a/Website/content/docs/getting-started/platform-support/index.md b/Website/content/docs/getting-started/platform-support/index.md index 0ed966015..92874b119 100644 --- a/Website/content/docs/getting-started/platform-support/index.md +++ b/Website/content/docs/getting-started/platform-support/index.md @@ -6,7 +6,7 @@ order: 3 # Platform Support -OfficeIMO is designed for COM-free document automation and does **not** require Microsoft Office to be installed for the workflows covered by this repo. `OfficeIMO.Word.Pdf` now uses the first-party `OfficeIMO.Pdf` engine; PDF workloads should still be tested on the target OS with the fonts and templates you plan to ship. The framework matrix below is taken from the current project files in this repo rather than from package-marketing copy. +OfficeIMO is designed for COM-free document automation and does **not** require Microsoft Office to be installed for the workflows covered by this repo. `OfficeIMO.Word.Pdf` and `OfficeIMO.Excel.Pdf` use the first-party `OfficeIMO.Pdf` engine; PDF workloads should still be tested on the target OS with the fonts and templates you plan to ship. The framework matrix below is taken from the current project files in this repo rather than from package-marketing copy. ## Target Frameworks @@ -22,6 +22,8 @@ OfficeIMO is designed for COM-free document automation and does **not** require | OfficeIMO.Word.Html | Yes | Yes | Yes | Yes (Windows build) | | OfficeIMO.Word.Markdown | Yes | Yes | Yes | Yes (Windows build) | | OfficeIMO.Word.Pdf | Yes | Yes | Yes | Yes (Windows build) | +| OfficeIMO.Excel.Pdf | Yes | Yes | Yes | Yes (Windows build) | +| OfficeIMO.Pdf | Yes | Yes | Yes | Yes (Windows build) | The `.NET Framework 4.7.2` target is included for some packages only when building on Windows. The `netstandard2.0`, `net8.0`, and `net10.0` targets are the main cross-platform story. @@ -36,7 +38,7 @@ The `.NET Framework 4.7.2` target is included for some packages only when buildi ## Native Dependencies -For the core document packages, OfficeIMO mainly relies on managed libraries such as the Open XML SDK and first-party drawing helpers. The main PDF caveat is layout fidelity: `OfficeIMO.Word.Pdf` is dependency-light, but host fonts and document templates still affect the rendered result. +For the core document packages, OfficeIMO mainly relies on managed libraries such as the Open XML SDK and first-party drawing helpers. The main PDF caveat is layout fidelity: `OfficeIMO.Word.Pdf` and `OfficeIMO.Excel.Pdf` are dependency-light, but host fonts and source templates still affect the rendered result. ## AOT Compilation @@ -44,7 +46,7 @@ OfficeIMO does **not** have one identical AOT story across every package. - **Best candidates:** `OfficeIMO.Markdown` and `OfficeIMO.CSV` - **Requires scenario testing:** `OfficeIMO.Word`, `OfficeIMO.Excel`, `OfficeIMO.PowerPoint`, and `OfficeIMO.Reader` -- **Treat separately:** `OfficeIMO.Word.Pdf` +- **Treat separately:** `OfficeIMO.Word.Pdf`, `OfficeIMO.Excel.Pdf`, and direct `OfficeIMO.Pdf` rendering Some projects in the repo enable trimming polyfills, but full NativeAOT success still depends on the dependency graph and the code paths your application exercises. diff --git a/Website/content/docs/index.md b/Website/content/docs/index.md index d66742ace..238f9cc04 100644 --- a/Website/content/docs/index.md +++ b/Website/content/docs/index.md @@ -20,7 +20,10 @@ Use the guided sections above when you are choosing a workflow or package family - Workflow and conversion packages: [OfficeIMO.Reader on NuGet](https://www.nuget.org/packages/OfficeIMO.Reader), [OfficeIMO.Word.Html on NuGet](https://www.nuget.org/packages/OfficeIMO.Word.Html), - [OfficeIMO.Word.Markdown on NuGet](https://www.nuget.org/packages/OfficeIMO.Word.Markdown) + [OfficeIMO.Word.Markdown on NuGet](https://www.nuget.org/packages/OfficeIMO.Word.Markdown), + [OfficeIMO.Word.Pdf on NuGet](https://www.nuget.org/packages/OfficeIMO.Word.Pdf), + [OfficeIMO.Excel.Pdf on NuGet](https://www.nuget.org/packages/OfficeIMO.Excel.Pdf), + [OfficeIMO.Pdf on NuGet](https://www.nuget.org/packages/OfficeIMO.Pdf) - PowerShell automation: [PSWriteOffice on PowerShell Gallery](https://www.powershellgallery.com/packages/PSWriteOffice) @@ -36,7 +39,7 @@ This site gives the best narrative coverage to the packages most teams start wit - `OfficeIMO.Reader` - `PSWriteOffice` -The repo also includes adjacent packages such as `OfficeIMO.Word.Pdf`, `OfficeIMO.Word.Html`, `OfficeIMO.Word.Markdown`, `OfficeIMO.Visio`, specialized reader extensions, and renderer projects. Some of those are linked from the package feeds below and the API reference, but not all of them have full narrative guides on the website yet. When a package is not fully covered here, the most accurate sources are usually: +The repo also includes adjacent packages such as `OfficeIMO.Word.Pdf`, `OfficeIMO.Excel.Pdf`, `OfficeIMO.Word.Html`, `OfficeIMO.Word.Markdown`, `OfficeIMO.Visio`, specialized reader extensions, and renderer projects. Some of those are linked from the package feeds below and the API reference, but not all of them have full narrative guides on the website yet. When a package is not fully covered here, the most accurate sources are usually: - the package README in the repo, - the generated API reference, @@ -54,7 +57,7 @@ The repo also includes adjacent packages such as `OfficeIMO.Word.Pdf`, `OfficeIM ## License -OfficeIMO is licensed under the [MIT License](https://github.com/EvotecIT/OfficeIMO/blob/master/LICENSE). Copyright (c) Przemyslaw Klys @ Evotec. If you need to review upstream runtime dependencies such as Open XML SDK or SixLabors.Fonts, see the [Third-Party Dependencies](/third-party/) page. +OfficeIMO is licensed under the [MIT License](https://github.com/EvotecIT/OfficeIMO/blob/master/LICENSE). Copyright (c) Przemyslaw Klys @ Evotec. If you need to review upstream runtime dependencies such as Open XML SDK, AngleSharp, or compatibility helper packages, see the [Third-Party Dependencies](/third-party/) page. ## Source Code diff --git a/Website/content/pages/third-party.md b/Website/content/pages/third-party.md index 1f51d5bde..17f4012b8 100644 --- a/Website/content/pages/third-party.md +++ b/Website/content/pages/third-party.md @@ -18,11 +18,12 @@ OfficeIMO packages are published under the [MIT License](https://github.com/Evot | OfficeIMO package or family | Upstream components used in the repo today | Why they are there | |---|---|---| | `OfficeIMO.Word` | `DocumentFormat.OpenXml` `[3.5.1, 4.0.0)` | OOXML document model and packaging; colors and image metadata use first-party `OfficeIMO.Drawing` | -| `OfficeIMO.Excel` | `DocumentFormat.OpenXml` `[3.5.1, 4.0.0)`, `SixLabors.Fonts` `1.0.1` | Workbook model, first-party image metadata, and font measurement/layout work | +| `OfficeIMO.Excel` | `DocumentFormat.OpenXml` `[3.5.1, 4.0.0)`, `Microsoft.Bcl.AsyncInterfaces` `10.0.8` and `System.Text.Json` `[10.0.7, 11.0.0)` on legacy targets | Workbook model, first-party image metadata, and compatibility helpers for older target frameworks | | `OfficeIMO.PowerPoint` | `DocumentFormat.OpenXml` `[3.5.1, 4.0.0)` | Presentation OOXML model and packaging | | `OfficeIMO.Word.Html` | `DocumentFormat.OpenXml` `[3.5.1, 4.0.0)`, `AngleSharp` `1.3.0`, `AngleSharp.Css` `1.0.0-beta.157` | HTML and CSS parsing for Word conversion workflows | | `OfficeIMO.Markdown.Html` | `AngleSharp` `1.3.0` | HTML parsing for Markdown conversion and bridge scenarios | | `OfficeIMO.Word.Pdf` | First-party `OfficeIMO.Word` and `OfficeIMO.Pdf` project references | Word-to-PDF conversion through the OfficeIMO PDF engine | +| `OfficeIMO.Excel.Pdf` | First-party `OfficeIMO.Excel` and `OfficeIMO.Pdf` project references | Excel-to-PDF conversion through the OfficeIMO PDF engine | | `OfficeIMO.Visio` | `System.IO.Packaging` `10.0.3` | OPC packaging support for `.vsdx` files; colors and image metadata use first-party `OfficeIMO.Drawing` | | `OfficeIMO.Markdown` | No third-party runtime package references | Core package is intentionally dependency-light | | `OfficeIMO.CSV` | No third-party runtime package references | Core package is intentionally dependency-light | @@ -35,17 +36,17 @@ Additional Microsoft compatibility helpers may appear on older target frameworks | Upstream project | License or model | OfficeIMO packages that use it | What to know | |---|---|---|---| | [DocumentFormat.OpenXml](https://www.nuget.org/packages/DocumentFormat.OpenXml/3.5.1) | MIT | Word, Excel, PowerPoint, Word.Html | This is Microsoft's official Open XML SDK and the main OOXML building block in the Office document packages. | -| [SixLabors.Fonts 1.0.1](https://www.nuget.org/packages/SixLabors.Fonts/1.0.1) | Apache License 2.0 | Excel | OfficeIMO currently pins the `1.0.1` line. As with other Six Labors packages, review the exact package version you ship instead of assuming newer lines follow the same terms. | | [AngleSharp](https://www.nuget.org/packages/AngleSharp/1.3.0) | MIT | Markdown.Html, Word.Html | Used for HTML parsing and DOM work. | | [AngleSharp.Css](https://www.nuget.org/packages/AngleSharp.Css/1.0.0-beta.157) | MIT | Word.Html | Adds CSS parsing on top of AngleSharp for HTML conversion flows. | +| [Microsoft.Bcl.AsyncInterfaces](https://www.nuget.org/packages/Microsoft.Bcl.AsyncInterfaces/10.0.8) | MIT | Excel legacy targets | Provides async interface compatibility for older target frameworks. | +| [System.Text.Json](https://www.nuget.org/packages/System.Text.Json/10.0.7) | MIT | Excel legacy targets | Provides JSON support where it is not supplied by the target framework. | | [System.IO.Packaging](https://www.nuget.org/packages/System.IO.Packaging/10.0.3) | MIT | Visio | Microsoft packaging primitives for OPC-style containers. | ## What We Recommend Teams Check 1. Review the exact `PackageReference` list for the OfficeIMO packages you ship, not just the repo root license. -2. Re-check exact Six Labors package versions whenever they change. -3. Re-check upstream terms whenever a dependency version changes, especially around imaging stacks. -4. Keep a copy of the upstream notices or license URLs in your own release/compliance workflow if your organization requires that. +2. Re-check upstream terms whenever a dependency version changes, especially around parsing, packaging, or compatibility helper libraries. +3. Keep a copy of the upstream notices or license URLs in your own release/compliance workflow if your organization requires that. ## How We Address This In The Website @@ -53,4 +54,4 @@ Additional Microsoft compatibility helpers may appear on older target frameworks - We document the public dependency surface here instead of hiding it in project files. - We keep the page scoped to real shipped package dependencies so it stays reviewable and current. -If you need the exact current references, start with the repository project files such as [`OfficeIMO.Word.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Word/OfficeIMO.Word.csproj), [`OfficeIMO.Excel.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Excel/OfficeIMO.Excel.csproj), [`OfficeIMO.Word.Pdf.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj), and [`OfficeIMO.Visio.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Visio/OfficeIMO.Visio.csproj). +If you need the exact current references, start with the repository project files such as [`OfficeIMO.Word.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Word/OfficeIMO.Word.csproj), [`OfficeIMO.Excel.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Excel/OfficeIMO.Excel.csproj), [`OfficeIMO.Word.Pdf.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj), [`OfficeIMO.Excel.Pdf.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Excel.Pdf/OfficeIMO.Excel.Pdf.csproj), and [`OfficeIMO.Visio.csproj`](https://github.com/EvotecIT/OfficeIMO/blob/master/OfficeIMO.Visio/OfficeIMO.Visio.csproj). diff --git a/Website/content/products/excel.md b/Website/content/products/excel.md index 90184768a..8f56805d6 100644 --- a/Website/content/products/excel.md +++ b/Website/content/products/excel.md @@ -85,3 +85,10 @@ OfficeIMO.Excel runs on Windows, Linux, and macOS. It generates standard `.xlsx` | [Worksheets guide](/docs/excel/worksheets/) | Create sheets, write values, and work with formulas. | | [Tables and ranges](/docs/excel/tables-ranges/) | Add structured tables, validation, and conditional formatting. | | [PSWriteOffice Excel cmdlets](/docs/pswriteoffice/excel/) | Generate workbooks from PowerShell automation. | + +## Related packages + +| Package | Description | +|---------|-------------| +| [OfficeIMO.Excel.Pdf](https://www.nuget.org/packages/OfficeIMO.Excel.Pdf) | Export Excel workbooks and selected worksheets to PDF | +| [OfficeIMO.Excel.GoogleSheets](https://www.nuget.org/packages/OfficeIMO.Excel.GoogleSheets) | Plan and export Excel workbooks to Google Sheets | diff --git a/Website/data/downloads_catalog.json b/Website/data/downloads_catalog.json index 706ab5958..97353546d 100644 --- a/Website/data/downloads_catalog.json +++ b/Website/data/downloads_catalog.json @@ -205,6 +205,20 @@ "install": "dotnet add package OfficeIMO.Word.Pdf", "accent": "#f97316" }, + { + "registry": "nuget", + "registry_label": "NuGet", + "registry_package": "OfficeIMO.Excel.Pdf", + "registry_url": "https://www.nuget.org/packages/OfficeIMO.Excel.Pdf", + "release_product": "officeimo.excel.pdf", + "title": "OfficeIMO.Excel.Pdf", + "summary": "Export Excel workbooks and selected worksheets to PDF using the first-party OfficeIMO.Pdf engine.", + "best_for": "workbook-first reporting flows that need printable PDF delivery without desktop Excel", + "install": "dotnet add package OfficeIMO.Excel.Pdf", + "product_url": "/products/excel/", + "docs_url": "/docs/excel/", + "accent": "#059669" + }, { "registry": "nuget", "registry_label": "NuGet", diff --git a/Website/data/faq.json b/Website/data/faq.json index 1bad933cf..0724bd129 100644 --- a/Website/data/faq.json +++ b/Website/data/faq.json @@ -42,11 +42,11 @@ "items": [ { "question": "What are the dependencies?", - "answer": "

The core Office document packages are built on DocumentFormat.OpenXml, while image- and converter-oriented packages also use components such as SixLabors.ImageSharp, AngleSharp, and QuestPDF depending on the feature area. The Markdown and CSV core packages stay dependency-light. See the Third-Party Dependencies page for the package-by-package breakdown and upstream license notes.

" + "answer": "

The core Office document packages are built on DocumentFormat.OpenXml and first-party OfficeIMO.Drawing primitives. HTML converter packages use AngleSharp, Excel carries compatibility helper packages on older targets, and the PDF packages use the first-party OfficeIMO.Pdf engine instead of external PDF/runtime layout libraries. The Markdown, CSV, Drawing, and PDF core packages stay dependency-light. See the Third-Party Dependencies page for the package-by-package breakdown and upstream license notes.

" }, { "question": "Can I convert Word documents to PDF?", - "answer": "

Yes. The OfficeIMO.Word.Pdf converter package supports Word-to-PDF conversion without Office. There are also converters for Word-to-HTML and Word-to-Markdown.

" + "answer": "

Yes. The OfficeIMO.Word.Pdf and OfficeIMO.Excel.Pdf converter packages support PDF export without Office. There are also converters for Word-to-HTML and Word-to-Markdown.

" }, { "question": "Is thread safety supported?", diff --git a/Website/data/product-details/excel.json b/Website/data/product-details/excel.json index ff45e72e3..8beea0efc 100644 --- a/Website/data/product-details/excel.json +++ b/Website/data/product-details/excel.json @@ -7,7 +7,7 @@ "docsUrl": "/docs/excel/", "apiUrl": "/api/excel/", "targets": [".NET 8.0", ".NET 10.0", ".NET Standard 2.0", ".NET Framework 4.7.2"], - "dependencies": ["DocumentFormat.OpenXml", "SixLabors.ImageSharp"], + "dependencies": ["DocumentFormat.OpenXml", "OfficeIMO.Drawing", "Microsoft.Bcl.AsyncInterfaces (netstandard2.0/net472)", "System.Text.Json (netstandard2.0/net472)"], "features": [ { "title": "Worksheets & Cells", "description": "Create workbooks with multiple sheets, typed cell values, formulas, and named ranges.", "icon_svg": "" }, { "title": "Tables & AutoFilter", "description": "Excel tables with AutoFilter, sorting, and automatic column sizing.", "icon_svg": "" }, @@ -16,5 +16,8 @@ { "title": "Parallel Execution", "description": "Multi-threaded bulk operations for AutoFit, cell writes, and large datasets.", "icon_svg": "" }, { "title": "Validation", "description": "List, whole number, decimal, date, time, text length, and custom validation rules.", "icon_svg": "" } ], - "relatedPackages": [] + "relatedPackages": [ + { "name": "OfficeIMO.Excel.Pdf", "description": "Export Excel workbooks to PDF", "install": "dotnet add package OfficeIMO.Excel.Pdf" }, + { "name": "OfficeIMO.Excel.GoogleSheets", "description": "Plan and export Excel workbooks to Google Sheets", "install": "dotnet add package OfficeIMO.Excel.GoogleSheets" } + ] } diff --git a/Website/data/product-details/powerpoint.json b/Website/data/product-details/powerpoint.json index 6e4e52f96..9c755c3bf 100644 --- a/Website/data/product-details/powerpoint.json +++ b/Website/data/product-details/powerpoint.json @@ -7,7 +7,7 @@ "docsUrl": "/docs/powerpoint/", "apiUrl": "/api/powerpoint/", "targets": [".NET 8.0", ".NET 10.0", ".NET Standard 2.0", ".NET Framework 4.7.2"], - "dependencies": ["DocumentFormat.OpenXml", "SixLabors.ImageSharp"], + "dependencies": ["DocumentFormat.OpenXml"], "features": [ { "title": "Slides & Layouts", "description": "Create slides with layouts, sections, transitions, and themes.", "icon_svg": "" }, { "title": "Text & Bullets", "description": "Text boxes with margins, auto-fit, bullets, and speaker notes.", "icon_svg": "" }, diff --git a/Website/data/product-details/word.json b/Website/data/product-details/word.json index cef1ba453..51d1d5744 100644 --- a/Website/data/product-details/word.json +++ b/Website/data/product-details/word.json @@ -7,7 +7,7 @@ "docsUrl": "/docs/word/", "apiUrl": "/api/word/", "targets": [".NET 8.0", ".NET 10.0", ".NET Standard 2.0", ".NET Framework 4.7.2"], - "dependencies": ["DocumentFormat.OpenXml", "SixLabors.ImageSharp"], + "dependencies": ["DocumentFormat.OpenXml", "OfficeIMO.Drawing"], "features": [ { "title": "Paragraphs & Styling", "description": "Full text formatting: bold, italic, underline, strikethrough, fonts, colors, alignment, and spacing.", "icon_svg": "" }, { "title": "Tables", "description": "Create tables with merge/split, borders, shading, and 105+ built-in styles.", "icon_svg": "" }, diff --git a/Website/data/release_placements.json b/Website/data/release_placements.json index e34fe4b87..09bef948b 100644 --- a/Website/data/release_placements.json +++ b/Website/data/release_placements.json @@ -54,6 +54,12 @@ "label": "Download OfficeIMO.Word.Pdf", "class": "imo-btn imo-btn-ghost" }, + "latest_excel_pdf": { + "product": "officeimo.excel.pdf", + "channel": "stable", + "label": "Download OfficeIMO.Excel.Pdf", + "class": "imo-btn imo-btn-ghost" + }, "latest_markdown_html": { "product": "officeimo.markdown.html", "channel": "stable", diff --git a/Website/pipeline.json b/Website/pipeline.json index 46dd1d5b6..63823f0bc 100644 --- a/Website/pipeline.json +++ b/Website/pipeline.json @@ -29,6 +29,14 @@ "framework": "net8.0", "skipIfProjectMissing": true }, + { + "task": "dotnet-build", + "id": "build-excel-pdf", + "project": "../OfficeIMO.Excel.Pdf/OfficeIMO.Excel.Pdf.csproj", + "configuration": "Release", + "framework": "net8.0", + "skipIfProjectMissing": true + }, { "task": "dotnet-build", "id": "build-powerpoint", @@ -69,6 +77,22 @@ "framework": "net8.0", "skipIfProjectMissing": true }, + { + "task": "dotnet-build", + "id": "build-pdf", + "project": "../OfficeIMO.Pdf/OfficeIMO.Pdf.csproj", + "configuration": "Release", + "framework": "net8.0", + "skipIfProjectMissing": true + }, + { + "task": "dotnet-build", + "id": "build-word-pdf", + "project": "../OfficeIMO.Word.Pdf/OfficeIMO.Word.Pdf.csproj", + "configuration": "Release", + "framework": "net8.0", + "skipIfProjectMissing": true + }, { "task": "release-hub", "id": "build-release-hub", @@ -85,6 +109,11 @@ "products": [ { "id": "officeimo.word", "name": "OfficeIMO.Word", "order": 10 }, { "id": "officeimo.excel", "name": "OfficeIMO.Excel", "order": 20 }, + { + "id": "officeimo.excel.pdf", + "name": "OfficeIMO.Excel.Pdf", + "order": 25 + }, { "id": "officeimo.powerpoint", "name": "OfficeIMO.PowerPoint", @@ -150,6 +179,11 @@ "label": "OfficeIMO.Word.Pdf", "match": ["*OfficeIMO.Word.Pdf*.zip", "*OfficeIMO.Word.Pdf*.nupkg"] }, + { + "product": "officeimo.excel.pdf", + "label": "OfficeIMO.Excel.Pdf", + "match": ["*OfficeIMO.Excel.Pdf*.zip", "*OfficeIMO.Excel.Pdf*.nupkg"] + }, { "product": "officeimo.markdownrenderer.wpf", "label": "OfficeIMO.MarkdownRenderer.Wpf", @@ -260,11 +294,14 @@ "sync-sources", "build-word", "build-excel", + "build-excel-pdf", "build-powerpoint", "build-markdown", "build-csv", "build-visio", "build-reader", + "build-pdf", + "build-word-pdf", "build-release-hub", "build-ecosystem-stats", "generate-excel-benchmark-data" From 596a501402c4808155df4e6eded3b09f36cf8ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 13:52:03 +0200 Subject: [PATCH 15/18] Address PDF form and data bar review feedback --- .../ExcelPdfConverterExtensions.cs | 35 +++++++++++-- OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs | 49 ++++++++++++++++++- OfficeIMO.Pdf/Model/PdfCellDataBar.cs | 16 +++++- .../Rendering/Writer/PdfWriter.Layout.cs | 46 ++++++++++++++--- OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 36 ++++++++++++++ OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs | 18 +++++++ 6 files changed, 189 insertions(+), 11 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index 25a163f95..9bd0a8992 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -2196,8 +2196,8 @@ private static RangeExportData ReadRangeExportData(ExcelSheetReader sheet, Excel double min = candidates.Min(candidate => candidate.Value); double max = candidates.Max(candidate => candidate.Value); foreach (var candidate in candidates) { - double ratio = max <= min ? 1D : Math.Max(0D, Math.Min(1D, (candidate.Value - min) / (max - min))); - dataBars[(candidate.Row, candidate.Column)] = new ConditionalDataBarCell(rule.DataBarColor!, ratio); + (double startRatio, double ratio) = GetDataBarGeometry(candidate.Value, min, max); + dataBars[(candidate.Row, candidate.Column)] = new ConditionalDataBarCell(rule.DataBarColor!, startRatio, ratio); } } @@ -2536,6 +2536,32 @@ private static bool IsWorksheetColumnHidden(IReadOnlyList c return hasAnyLink ? links : null; } + private static (double StartRatio, double Ratio) GetDataBarGeometry(double value, double min, double max) { + if (max <= min) { + return value < 0D ? (0D, 1D) : (0D, 1D); + } + + if (min < 0D && max > 0D) { + double range = max - min; + double zeroRatio = Math.Max(0D, Math.Min(1D, -min / range)); + if (value >= 0D) { + return (zeroRatio, Math.Max(0D, Math.Min(1D - zeroRatio, value / range))); + } + + double ratio = Math.Max(0D, Math.Min(zeroRatio, -value / range)); + return (zeroRatio - ratio, ratio); + } + + if (max <= 0D) { + double maxMagnitude = Math.Max(Math.Abs(min), Math.Abs(max)); + double ratio = maxMagnitude <= 0D ? 0D : Math.Max(0D, Math.Min(1D, Math.Abs(value) / maxMagnitude)); + return (1D - ratio, ratio); + } + + double positiveRatio = Math.Max(0D, Math.Min(1D, (value - min) / (max - min))); + return (0D, positiveRatio); + } + private static bool IsSupportedPdfHyperlink(ExcelHyperlinkSnapshot hyperlink, string currentSheetName) { if (hyperlink.IsExternal) { return Uri.TryCreate(hyperlink.Target, UriKind.Absolute, out _); @@ -3540,6 +3566,7 @@ private static PdfCore.PageMargins GetEffectiveMargins(ExcelPdfSaveOptions optio dataBars ??= new Dictionary<(int Row, int Column), PdfCore.PdfCellDataBar>(); dataBars[(localRow, conditionalDataBar.Key.Column - columnOffset)] = new PdfCore.PdfCellDataBar { Color = fill.Value, + StartRatio = conditionalDataBar.Value.StartRatio, Ratio = conditionalDataBar.Value.Ratio }; } @@ -4297,12 +4324,14 @@ public ConditionalFillData(IReadOnlyDictionary<(int Row, int Column), string> fi } private sealed class ConditionalDataBarCell { - public ConditionalDataBarCell(string color, double ratio) { + public ConditionalDataBarCell(string color, double startRatio, double ratio) { Color = color; + StartRatio = startRatio; Ratio = ratio; } public string Color { get; } + public double StartRatio { get; } public double Ratio { get; } } diff --git a/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs b/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs index cabacfa91..bcf409465 100644 --- a/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs +++ b/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs @@ -692,9 +692,23 @@ private static void SetFieldValue(Dictionary objects, Pd string firstValue = values[0]; if (string.Equals(fieldType, "Btn", StringComparison.Ordinal)) { string name = string.IsNullOrEmpty(firstValue) ? "Off" : firstValue; + bool isRadioButtonGroup = (fieldFlags & RadioButtonFlag) != 0; + if (isRadioButtonGroup) { + if (values.Count > 1) { + throw new ArgumentException("PDF radio button field cannot be filled with multiple values.", nameof(value)); + } + + if (!string.Equals(name, "Off", StringComparison.Ordinal)) { + HashSet availableStates = CollectButtonNormalAppearanceStates(objects, field, new HashSet()); + if (!availableStates.Contains(name)) { + throw new ArgumentException($"PDF radio button field cannot be filled with value '{name}' because it is not one of the available appearance states.", nameof(value)); + } + } + } + field.Items["V"] = new PdfName(name); field.Items["AS"] = new PdfName(name); - SetWidgetAppearanceStates(objects, field, name, (fieldFlags & RadioButtonFlag) != 0, new HashSet(), ref nextObjectNumber); + SetWidgetAppearanceStates(objects, field, name, isRadioButtonGroup, new HashSet(), ref nextObjectNumber); return; } @@ -762,6 +776,39 @@ private static void SetWidgetAppearanceStates(Dictionary } } + private static HashSet CollectButtonNormalAppearanceStates(Dictionary objects, PdfDictionary field, HashSet visited) { + var states = new HashSet(StringComparer.Ordinal); + CollectButtonNormalAppearanceStates(objects, field, states, visited); + states.Remove("Off"); + return states; + } + + private static void CollectButtonNormalAppearanceStates(Dictionary objects, PdfDictionary field, HashSet states, HashSet visited) { + if (IsWidget(field) && + TryGetNormalAppearanceObject(objects, field, out PdfObject? normalAppearance) && + normalAppearance is PdfDictionary appearanceStates) { + foreach (string stateName in appearanceStates.Items.Keys) { + states.Add(stateName); + } + } + + if (!field.Items.TryGetValue("Kids", out var kidsObject) || + ResolveObject(objects, kidsObject) is not PdfArray kids) { + return; + } + + for (int i = 0; i < kids.Items.Count; i++) { + PdfObject kidObject = kids.Items[i]; + if (kidObject is PdfReference reference && !visited.Add(reference.ObjectNumber)) { + continue; + } + + if (ResolveObject(objects, kidObject) is PdfDictionary kid) { + CollectButtonNormalAppearanceStates(objects, kid, states, visited); + } + } + } + private static bool HasButtonNormalAppearanceState(Dictionary objects, PdfDictionary widget, string stateName) { if (string.IsNullOrEmpty(stateName)) { return false; diff --git a/OfficeIMO.Pdf/Model/PdfCellDataBar.cs b/OfficeIMO.Pdf/Model/PdfCellDataBar.cs index 9916e4a84..5102546a4 100644 --- a/OfficeIMO.Pdf/Model/PdfCellDataBar.cs +++ b/OfficeIMO.Pdf/Model/PdfCellDataBar.cs @@ -5,6 +5,7 @@ namespace OfficeIMO.Pdf; /// public sealed class PdfCellDataBar { private double _ratio; + private double _startRatio; /// Fill color used for the data bar. public PdfColor Color { get; set; } = PdfColor.LightGray; @@ -21,11 +22,24 @@ public double Ratio { } } + /// Horizontal start position as a 0..1 fraction of the cell content width. + public double StartRatio { + get => _startRatio; + set { + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0 || value > 1) { + throw new ArgumentOutOfRangeException(nameof(StartRatio), "PDF table data bar start ratio must be a finite value between 0 and 1."); + } + + _startRatio = value; + } + } + /// Creates a deep copy of this data bar. public PdfCellDataBar Clone() { return new PdfCellDataBar { Color = Color, - Ratio = Ratio + Ratio = Ratio, + StartRatio = StartRatio }; } } diff --git a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs index 4aee1b952..c18249ec5 100644 --- a/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs +++ b/OfficeIMO.Pdf/Rendering/Writer/PdfWriter.Layout.cs @@ -1171,10 +1171,12 @@ private static bool DrawTableCellDataBars(StringBuilder sb, PdfTableStyle style, double padRight = GetTableCellPaddingRight(style, rowIndex, column); double padTop = GetTableCellPaddingTop(style, rowIndex, column); double padBottom = GetTableCellPaddingBottom(style, rowIndex, column); - double barWidth = System.Math.Max(0D, cellWidth - padLeft - padRight) * dataBar.Ratio; + double contentWidth = System.Math.Max(0D, cellWidth - padLeft - padRight); + double barX = cellX + padLeft + contentWidth * dataBar.StartRatio; + double barWidth = contentWidth * dataBar.Ratio; double barHeight = System.Math.Max(0D, cellHeight - padTop - padBottom); if (barWidth > 0.001D && barHeight > 0.001D) { - DrawRowFill(sb, dataBar.Color, cellX + padLeft, cellBottom + padBottom, barWidth, barHeight); + DrawRowFill(sb, dataBar.Color, barX, cellBottom + padBottom, barWidth, barHeight); drawn = true; } } @@ -2880,7 +2882,8 @@ void RenderRadioButtonGroupBlock(RadioButtonGroupBlock block, double containerX, double spacingBefore = ResolveTopLevelSpacingBefore(block.SpacingBefore); double height = block.Height; double needed = spacingBefore + height + block.SpacingAfter; - EnsureFixedFlowBlockFits("Radio button group", block.Size, needed, containerWidth); + double groupWidth = GetRadioButtonGroupWidth(block); + EnsureFixedFlowBlockFits("Radio button group", groupWidth, needed, containerWidth); if (y - needed < currentOpts.MarginBottom) { NewPage(); spacingBefore = 0D; @@ -2890,7 +2893,7 @@ void RenderRadioButtonGroupBlock(RadioButtonGroupBlock block, double containerX, y -= spacingBefore; } - double x = GetAlignedObjectX(containerX, containerWidth, block.Size, block.Align); + double x = GetAlignedObjectX(containerX, containerWidth, groupWidth, block.Align); currentPage!.FormFields.Add(new FormFieldAnnotation { X1 = x, Y1 = y - height, @@ -2904,6 +2907,7 @@ void RenderRadioButtonGroupBlock(RadioButtonGroupBlock block, double containerX, ButtonGap = block.Gap, Style = block.Style }); + RenderRadioButtonLabels(block, x, y); pageDirty = true; y -= height + block.SpacingAfter; } @@ -2924,7 +2928,7 @@ static string GetFormFieldBlockName(IPdfBlock block) { return "Choice field"; } - static double GetFormFieldWidth(IPdfBlock block) { + double GetFormFieldWidth(IPdfBlock block) { if (block is TextFieldBlock textField) { return textField.Width; } @@ -2934,7 +2938,7 @@ static double GetFormFieldWidth(IPdfBlock block) { } if (block is RadioButtonGroupBlock radioButtonGroup) { - return radioButtonGroup.Size; + return GetRadioButtonGroupWidth(radioButtonGroup); } return ((ChoiceFieldBlock)block).Width; @@ -3050,6 +3054,7 @@ void AddFormFieldAnnotation(IPdfBlock block, double x, double topY) { ButtonGap = radioButtonGroup.Gap, Style = radioButtonGroup.Style }); + RenderRadioButtonLabels(radioButtonGroup, x, topY); return; } @@ -3082,6 +3087,35 @@ void EnsureFixedFlowBlockFits(string blockName, double blockWidth, double blockH } } + double GetRadioButtonGroupLabelFontSize(RadioButtonGroupBlock block) => + System.Math.Min(System.Math.Max(8D, currentOpts.DefaultFontSize), System.Math.Max(8D, block.Size)); + + double GetRadioButtonGroupLabelGap(RadioButtonGroupBlock block) => + System.Math.Max(4D, block.Size * 0.4D); + + double GetRadioButtonGroupWidth(RadioButtonGroupBlock block) { + PdfStandardFont font = ChooseNormal(currentOpts.DefaultFont); + double fontSize = GetRadioButtonGroupLabelFontSize(block); + double labelWidth = block.Options.Max(option => EstimateSimpleTextWidth(option, font, fontSize)); + return block.Size + GetRadioButtonGroupLabelGap(block) + labelWidth; + } + + void RenderRadioButtonLabels(RadioButtonGroupBlock block, double x, double topY) { + PdfStandardFont font = ChooseNormal(currentOpts.DefaultFont); + string fontResource = GetStandardFontResourceName(font, font); + double fontSize = GetRadioButtonGroupLabelFontSize(block); + double labelX = x + block.Size + GetRadioButtonGroupLabelGap(block); + double ascender = GetAscender(font, fontSize); + double descender = GetDescender(font, fontSize); + double labelBaselineOffset = (block.Size - ascender - descender) / 2D + descender; + + for (int i = 0; i < block.Options.Count; i++) { + double optionTop = topY - i * (block.Size + block.Gap); + double baseline = optionTop - block.Size + labelBaselineOffset; + AppendPageText(sb, block.Options[i], fontResource, fontSize, block.Style.TextColor, labelX, baseline); + } + } + void ValidateHorizontalRule(PdfHorizontalRuleStyle rule) { if (rule.Thickness <= 0 || double.IsNaN(rule.Thickness) || double.IsInfinity(rule.Thickness)) { throw new ArgumentException("Horizontal rule thickness must be a positive finite value."); diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index ce86101b0..b12097f9e 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Reflection; using System.Text; +using System.Text.RegularExpressions; using UglyToad.PdfPig; using Xunit; using PdfCore = OfficeIMO.Pdf; @@ -1085,6 +1086,41 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Conditional_DataBar_Overlays() { Assert.Contains(" re f", rawPdf, StringComparison.Ordinal); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Preserves_Negative_Conditional_DataBars() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfConditionalNegativeDataBar.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "Conditional")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "Delta"); + sheet.Cell(2, 1, -100); + sheet.Cell(3, 1, 0); + sheet.Cell(4, 1, 100); + sheet.AddConditionalDataBar("A2:A4", "FF5B9BD5"); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 1, + PageSize = new PdfCore.PageSize(360, 240), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + string rawPdf = Encoding.ASCII.GetString(bytes); + MatchCollection barRects = Regex.Matches(rawPdf, @"0\.357 0\.608 0\.835 rg\s+(?-?\d+(?:\.\d+)?) (?-?\d+(?:\.\d+)?) (?-?\d+(?:\.\d+)?) (?-?\d+(?:\.\d+)?) re f"); + + Assert.Equal(2, barRects.Count); + double firstX = double.Parse(barRects[0].Groups["x"].Value, CultureInfo.InvariantCulture); + double secondX = double.Parse(barRects[1].Groups["x"].Value, CultureInfo.InvariantCulture); + double firstWidth = double.Parse(barRects[0].Groups["width"].Value, CultureInfo.InvariantCulture); + double secondWidth = double.Parse(barRects[1].Groups["width"].Value, CultureInfo.InvariantCulture); + + Assert.True(firstX < secondX); + Assert.InRange(Math.Abs(firstWidth - secondWidth), 0D, 1D); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Maps_Conditional_IconSet_Indicators() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfConditionalIconSet.xlsx"); diff --git a/OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs b/OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs index 1ee85ebf5..88d23a6c3 100644 --- a/OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfFormCreationTests.cs @@ -220,6 +220,11 @@ public void RadioButtonGroup_CreatesInspectableAcroFormField() { Assert.Contains("/Ff 49152", raw); Assert.Contains("/Kids [", raw); Assert.Contains("/V /Cash", raw); + using var pdfDocument = PdfDocument.Open(new MemoryStream(pdf)); + string pageText = pdfDocument.GetPage(1).Text; + Assert.Contains("Card", pageText); + Assert.Contains("Cash", pageText); + Assert.Contains("Wire", pageText); PdfFormField field = Assert.Single(info.FormFields); Assert.Equal("Payment.Method", field.Name); Assert.Equal(PdfFormFieldKind.Button, field.Kind); @@ -264,6 +269,19 @@ public void RadioButtonGroup_CanBeFilledAndFlattened() { Assert.Contains("/OfficeIMOForm", raw); } + [Fact] + public void RadioButtonGroup_RejectsUnknownFillValue() { + byte[] pdf = PdfDoc.Create() + .RadioButtonGroup("Payment.Method", new[] { "Card", "Cash", "Wire" }, value: "Card") + .ToBytes(); + + ArgumentException exception = Assert.Throws(() => PdfFormFiller.FillFields(pdf, new Dictionary { + ["Payment.Method"] = "WireTransfer" + })); + + Assert.Contains("not one of the available appearance states", exception.Message); + } + [Fact] public void GeneratedFields_CanStyleAppearances() { var style = new PdfFormFieldStyle { From a8786bb1366de50cd6aa47bcd87ba5e75dae159a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 14:04:41 +0200 Subject: [PATCH 16/18] Fix Excel PDF website release placement --- Website/data/downloads_catalog.json | 1 - Website/data/release_placements.json | 6 ------ 2 files changed, 7 deletions(-) diff --git a/Website/data/downloads_catalog.json b/Website/data/downloads_catalog.json index 97353546d..fd2a1f891 100644 --- a/Website/data/downloads_catalog.json +++ b/Website/data/downloads_catalog.json @@ -210,7 +210,6 @@ "registry_label": "NuGet", "registry_package": "OfficeIMO.Excel.Pdf", "registry_url": "https://www.nuget.org/packages/OfficeIMO.Excel.Pdf", - "release_product": "officeimo.excel.pdf", "title": "OfficeIMO.Excel.Pdf", "summary": "Export Excel workbooks and selected worksheets to PDF using the first-party OfficeIMO.Pdf engine.", "best_for": "workbook-first reporting flows that need printable PDF delivery without desktop Excel", diff --git a/Website/data/release_placements.json b/Website/data/release_placements.json index 09bef948b..e34fe4b87 100644 --- a/Website/data/release_placements.json +++ b/Website/data/release_placements.json @@ -54,12 +54,6 @@ "label": "Download OfficeIMO.Word.Pdf", "class": "imo-btn imo-btn-ghost" }, - "latest_excel_pdf": { - "product": "officeimo.excel.pdf", - "channel": "stable", - "label": "Download OfficeIMO.Excel.Pdf", - "class": "imo-btn imo-btn-ghost" - }, "latest_markdown_html": { "product": "officeimo.markdown.html", "channel": "stable", From b99ba6bdef1e30b2618086d6272eb96f53ae218e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 14:12:33 +0200 Subject: [PATCH 17/18] Address PDF export review follow-ups --- .../ExcelPdfConverterExtensions.cs | 79 ++++++++++++++++--- OfficeIMO.Excel/ExcelSheet.PageSetup.cs | 8 ++ OfficeIMO.Excel/ExcelSheet.PrintSettings.cs | 10 +++ OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs | 18 +++++ OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 36 +++++++++ .../Pdf/Word.SaveAsPdf.Footnotes.cs | 4 +- .../Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs | 45 +++++++++++ .../WordPdfConverterExtensions.Native.cs | 13 ++- 8 files changed, 197 insertions(+), 16 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index 9bd0a8992..8e0218215 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -2992,17 +2992,31 @@ private static IReadOnlyList CreateTableChunks(WorksheetPdfExportPla int headerRowCount = Math.Min(plan.ExportData.HeaderRowCount, plan.ExportedRows); var chunks = new List(rowChunks.Count * columnChunks.Count); - foreach (TableAxisChunk rowChunk in rowChunks) { - IReadOnlyList rowIndexes = CreateChunkRowIndexes(rowChunk, headerRowCount); - int chunkHeaderRows = Math.Min(headerRowCount, rowIndexes.Count); + if (plan.PageSetup?.PageOrder == ExcelPageOrder.OverThenDown) { + foreach (TableAxisChunk rowChunk in rowChunks) { + AddChunksForRow(rowChunk, columnChunks, headerRowCount, chunks); + } + } else { foreach (TableAxisChunk columnChunk in columnChunks) { - chunks.Add(new TableChunk(rowIndexes, chunkHeaderRows, columnChunk.Start, columnChunk.Count)); + foreach (TableAxisChunk rowChunk in rowChunks) { + IReadOnlyList rowIndexes = CreateChunkRowIndexes(rowChunk, headerRowCount); + int chunkHeaderRows = Math.Min(headerRowCount, rowIndexes.Count); + chunks.Add(new TableChunk(rowIndexes, chunkHeaderRows, columnChunk.Start, columnChunk.Count)); + } } } return chunks; } + private static void AddChunksForRow(TableAxisChunk rowChunk, IReadOnlyList columnChunks, int headerRowCount, List chunks) { + IReadOnlyList rowIndexes = CreateChunkRowIndexes(rowChunk, headerRowCount); + int chunkHeaderRows = Math.Min(headerRowCount, rowIndexes.Count); + foreach (TableAxisChunk columnChunk in columnChunks) { + chunks.Add(new TableChunk(rowIndexes, chunkHeaderRows, columnChunk.Start, columnChunk.Count)); + } + } + private static IReadOnlyList CreateChunkRowIndexes(TableAxisChunk rowChunk, int headerRowCount) { var indexes = new List(rowChunk.Count + headerRowCount); if (rowChunk.Start > 0 && headerRowCount > 0) { @@ -3976,12 +3990,13 @@ private static bool TryFormatElapsedDuration(double value, string formatCode, ou return false; } - result = ReplaceIgnoreCase(result, "hh", duration.Hours.ToString("D2", CultureInfo.InvariantCulture)); - result = ReplaceIgnoreCase(result, "h", duration.Hours.ToString(CultureInfo.InvariantCulture)); - result = ReplaceIgnoreCase(result, "mm", duration.Minutes.ToString("D2", CultureInfo.InvariantCulture)); - result = ReplaceIgnoreCase(result, "m", duration.Minutes.ToString(CultureInfo.InvariantCulture)); - result = ReplaceIgnoreCase(result, "ss", duration.Seconds.ToString("D2", CultureInfo.InvariantCulture)); - result = ReplaceIgnoreCase(result, "s", duration.Seconds.ToString(CultureInfo.InvariantCulture)); + result = ReplaceUnquotedIgnoreCase(result, "hh", duration.Hours.ToString("D2", CultureInfo.InvariantCulture)); + result = ReplaceUnquotedIgnoreCase(result, "h", duration.Hours.ToString(CultureInfo.InvariantCulture)); + result = ReplaceUnquotedIgnoreCase(result, "mm", duration.Minutes.ToString("D2", CultureInfo.InvariantCulture)); + result = ReplaceUnquotedIgnoreCase(result, "m", duration.Minutes.ToString(CultureInfo.InvariantCulture)); + result = ReplaceUnquotedIgnoreCase(result, "ss", duration.Seconds.ToString("D2", CultureInfo.InvariantCulture)); + result = ReplaceUnquotedIgnoreCase(result, "s", duration.Seconds.ToString(CultureInfo.InvariantCulture)); + result = RemoveExcelFormatQuotes(result); text = negative ? "-" + result : result; return true; } @@ -4111,6 +4126,50 @@ private static string ApplyQuotedLiterals(string formatCode, string numericValue return prefix + numericValue + suffix; } + private static string ReplaceUnquotedIgnoreCase(string value, string oldValue, string newValue) { + var builder = new System.Text.StringBuilder(value.Length); + bool inQuote = false; + int index = 0; + while (index < value.Length) { + char ch = value[index]; + if (ch == '"') { + inQuote = !inQuote; + builder.Append(ch); + index++; + continue; + } + + if (!inQuote && + index + oldValue.Length <= value.Length && + string.Compare(value, index, oldValue, 0, oldValue.Length, StringComparison.OrdinalIgnoreCase) == 0) { + builder.Append(newValue); + index += oldValue.Length; + continue; + } + + builder.Append(ch); + index++; + } + + return builder.ToString(); + } + + private static string RemoveExcelFormatQuotes(string value) { + var builder = new System.Text.StringBuilder(value.Length); + bool inQuote = false; + for (int i = 0; i < value.Length; i++) { + char ch = value[i]; + if (ch == '"') { + inQuote = !inQuote; + continue; + } + + builder.Append(ch); + } + + return builder.ToString(); + } + private static bool HasNumberPlaceholder(string formatCode, int start, int end) { for (int i = start; i < end && i < formatCode.Length; i++) { char ch = formatCode[i]; diff --git a/OfficeIMO.Excel/ExcelSheet.PageSetup.cs b/OfficeIMO.Excel/ExcelSheet.PageSetup.cs index 3f6b74533..6bbb06984 100644 --- a/OfficeIMO.Excel/ExcelSheet.PageSetup.cs +++ b/OfficeIMO.Excel/ExcelSheet.PageSetup.cs @@ -81,4 +81,12 @@ public enum ExcelPageOrientation { /// Landscape orientation (horizontal). Landscape } + + /// Worksheet print page order. + public enum ExcelPageOrder { + /// Print pages down first, then over to the next column group. + DownThenOver, + /// Print pages over first, then down to the next row group. + OverThenDown + } } diff --git a/OfficeIMO.Excel/ExcelSheet.PrintSettings.cs b/OfficeIMO.Excel/ExcelSheet.PrintSettings.cs index 983175440..85246600b 100644 --- a/OfficeIMO.Excel/ExcelSheet.PrintSettings.cs +++ b/OfficeIMO.Excel/ExcelSheet.PrintSettings.cs @@ -30,6 +30,11 @@ public sealed class ExcelSheetPageSetup { /// Manual worksheet print scale percentage, when configured. /// public uint? Scale { get; internal set; } + + /// + /// Worksheet page order for multi-page print/export output, when configured. + /// + public ExcelPageOrder? PageOrder { get; internal set; } } /// @@ -85,6 +90,11 @@ public ExcelSheetPageSetup GetPageSetup() { result.FitToWidth = pageSetup.FitToWidth?.Value; result.FitToHeight = pageSetup.FitToHeight?.Value; result.Scale = pageSetup.Scale?.Value; + if (pageSetup.PageOrder?.Value == PageOrderValues.OverThenDown) { + result.PageOrder = ExcelPageOrder.OverThenDown; + } else if (pageSetup.PageOrder?.Value == PageOrderValues.DownThenOver) { + result.PageOrder = ExcelPageOrder.DownThenOver; + } } PageMargins? margins = WorksheetRoot.GetFirstChild(); diff --git a/OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs b/OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs index 721930342..80d4ed47a 100644 --- a/OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs +++ b/OfficeIMO.Pdf/Core/PdfDoc.Blocks.cs @@ -797,6 +797,24 @@ private static void ValidateOpacity(double? opacity, string paramName) { } public sealed partial class PdfDoc { + /// + /// Checks whether image bytes can be embedded by the first-party PDF writer. + /// + public static bool TryValidateImageBytes(byte[] data, out OfficeImageInfo? imageInfo, out string? unsupportedReason) { + imageInfo = null; + unsupportedReason = null; + try { + imageInfo = ValidateImageBytes(data); + return true; + } catch (NotSupportedException ex) { + unsupportedReason = ex.Message; + return false; + } catch (ArgumentException ex) { + unsupportedReason = ex.Message; + return false; + } + } + internal static OfficeImageInfo ValidateImageBytes(byte[] data) { if (OfficeImageReader.TryIdentify(data, null, out var info)) { if (info.Format == OfficeImageFormat.Jpeg) { diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index b12097f9e..61817ed33 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -599,6 +599,37 @@ public void SaveAsPdf_ExcelWorkbook_Honors_Manual_Column_Page_Breaks() { Assert.Contains("RightValueC", disabledPdf.GetPage(1).Text); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Defaults_To_DownThenOver_Page_Order() { + string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfDefaultPageOrder.xlsx"); + + byte[] bytes; + using (ExcelDocument document = ExcelDocument.Create(workbookPath, "PageOrder")) { + ExcelSheet sheet = document.Sheets[0]; + sheet.Cell(1, 1, "TopLeftPage"); + sheet.Cell(3, 1, "BottomLeftPage"); + sheet.Cell(1, 3, "TopRightPage"); + sheet.Cell(3, 3, "BottomRightPage"); + sheet.AddManualRowPageBreak(2); + sheet.AddManualColumnPageBreak(2); + document.Save(false); + + bytes = document.SaveAsPdf(new ExcelPdfSaveOptions { + IncludeSheetHeadings = false, + HeaderRowCount = 0, + PageSize = new PdfCore.PageSize(360, 260), + Margins = PdfCore.PageMargins.Uniform(24) + }); + } + + using PdfDocument pdf = PdfDocument.Open(new MemoryStream(bytes)); + Assert.Equal(4, pdf.NumberOfPages); + Assert.Contains("TopLeftPage", pdf.GetPage(1).Text); + Assert.Contains("BottomLeftPage", pdf.GetPage(2).Text); + Assert.Contains("TopRightPage", pdf.GetPage(3).Text); + Assert.Contains("BottomRightPage", pdf.GetPage(4).Text); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Maps_Worksheet_HeaderFooter_Text_Zones() { string workbookPath = Path.Combine(_directoryWithFiles, "ExcelPdfHeaderFooter.xlsx"); @@ -1509,6 +1540,8 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Elapsed_Time_And_Quoted_Number_Literals sheet.CellAt(2, 2).SetValue(1.5).SetNumberFormat("[h]:mm"); sheet.Cell(3, 1, "Units"); sheet.CellAt(3, 2).SetValue(12).SetNumberFormat("0 \"kg\""); + sheet.Cell(4, 1, "Elapsed units"); + sheet.CellAt(4, 2).SetValue(1.5).SetNumberFormat("[h] \"hours\""); document.Save(false); @@ -1526,7 +1559,10 @@ public void SaveAsPdf_ExcelWorkbook_Maps_Elapsed_Time_And_Quoted_Number_Literals Assert.Contains("36:00", text); Assert.Contains("Units", text); Assert.Contains("12 kg", text); + Assert.Contains("Elapsed units", text); + Assert.Contains("36 hours", text); Assert.DoesNotContain("12:00", text); + Assert.DoesNotContain("hour0", text); Assert.DoesNotContain("kg12", text); } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs index 027301178..a8509d98c 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.Footnotes.cs @@ -75,9 +75,9 @@ public void SaveAsPdf_OfficeIMOEngine_Renders_Endnote_Markers_And_Text() { string allText = string.Concat(pdf.GetPages().Select(p => p.Text)); string normalizedText = Regex.Replace(allText, @"\s+", " "); Assert.Contains("Native footnote here1", allText); - Assert.Contains("Native endnote here2", allText); + Assert.Contains("Native endnote here1", allText); Assert.Contains("1 Native footnote text", normalizedText); - Assert.Contains("2 Native endnote text", normalizedText); + Assert.Contains("1 Native endnote text", normalizedText); Assert.Contains("Native after notes", allText); } } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs index 4629a02c2..9b30bd862 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.ImagesAndHyperlinks.cs @@ -1,3 +1,4 @@ +using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using OfficeIMO.Word; using OfficeIMO.Word.Pdf; @@ -108,6 +109,37 @@ public void SaveAsPdf_OfficeIMOEngine_Exports_Loaded_Inline_Paragraph_Images() { Assert.Contains("after loaded image", text); } + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Skips_Loaded_Unsupported_Png_Images() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeLoadedUnsupportedPng.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeLoadedUnsupportedPng.pdf"); + string imagePath = Path.Combine(_directoryWithImages, "EvotecLogo.png"); + var options = new PdfSaveOptions { + IncludePageNumbers = false + }; + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddParagraph("Before unsupported image"); + document.AddParagraph().AddImage(imagePath, 48, 48); + document.AddParagraph("After unsupported image"); + document.Save(); + } + + ReplaceFirstMainDocumentImagePart(docPath, CreateUnsupportedInterlacedPng()); + + using (WordDocument document = WordDocument.Load(docPath)) { + document.SaveAsPdf(pdfPath, options); + } + + Assert.Contains(options.Warnings, warning => + warning.Code == "NativeBodyImageUnsupported" && + warning.Message.Contains("PNG", StringComparison.OrdinalIgnoreCase)); + using PdfDocument pdf = PdfDocument.Open(pdfPath); + string text = pdf.GetPage(1).Text; + Assert.Contains("Before unsupported image", text); + Assert.Contains("After unsupported image", text); + } + [Fact] public void SaveAsPdf_OfficeIMOEngine_Maps_Body_PictureControl_To_Image() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativePictureControl.docx"); @@ -172,4 +204,17 @@ public void SaveAsPdf_OfficeIMOEngine_Maps_Table_Cell_PictureControl_To_Image() using PdfDocument pdf = PdfDocument.Open(bytes); Assert.Contains("Logo content control in a table", pdf.GetPage(1).Text); } + + private static void ReplaceFirstMainDocumentImagePart(string docPath, byte[] bytes) { + using WordprocessingDocument package = WordprocessingDocument.Open(docPath, true); + ImagePart imagePart = package.MainDocumentPart!.ImageParts.First(); + using var stream = new MemoryStream(bytes); + imagePart.FeedData(stream); + } + + private static byte[] CreateUnsupportedInterlacedPng() { + byte[] bytes = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAABGdBTAAAACklEQVR42mP8z8AABQMBgA4uA1sAAAAASUVORK5CYII="); + bytes[28] = 1; + return bytes; + } } diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs index 3dba01217..47319b178 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -5003,7 +5003,7 @@ private static void AddNativeFootnote(WordFootNote footNote, List f return; } - int number = footnoteNumbersById.Count + 1; + int number = footnoteNumbersById.Keys.Count(key => key > 0) + 1; footnoteNumbersById[key] = number; footnotes.Add(new PdfFootnote { Number = number, @@ -5022,7 +5022,7 @@ private static void AddNativeEndnote(WordEndNote endNote, List foot return; } - int number = footnoteNumbersById.Count + 1; + int number = footnoteNumbersById.Keys.Count(key => key < 0) + 1; footnoteNumbersById[key] = number; footnotes.Add(new PdfFootnote { Number = number, @@ -5152,11 +5152,16 @@ private static bool IsNativePdfSupportedImageBytes(byte[] bytes, out string? uns return false; } - if (info.Format == OfficeImageFormat.Jpeg || info.Format == OfficeImageFormat.Png) { + if (info.Format == OfficeImageFormat.Jpeg) { return true; } - unsupportedReason = "Detected " + info.Format + " (" + info.MimeType + ")."; + if (info.Format == OfficeImageFormat.Png && + PdfCore.PdfDoc.TryValidateImageBytes(bytes, out _, out unsupportedReason)) { + return true; + } + + unsupportedReason ??= "Detected " + info.Format + " (" + info.MimeType + ")."; return false; } From 4762efa492f053606a387a36383693a562787692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 1 Jun 2026 14:32:42 +0200 Subject: [PATCH 18/18] Address PDF export review follow-ups --- .../ExcelPdfConverterExtensions.cs | 27 +++++- OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs | 28 ++++-- OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs | 22 +++++ OfficeIMO.Tests/Pdf/PdfFormFillerTests.cs | 91 +++++++++++++++++++ .../Pdf/Word.SaveAsPdf.PageNumbers.cs | 45 +++++++++ .../WordPdfConverterExtensions.Native.cs | 12 +++ Website/.powerforge/seo-baseline.json | 6 +- 7 files changed, 219 insertions(+), 12 deletions(-) diff --git a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs index 8e0218215..7b68e1430 100644 --- a/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs +++ b/OfficeIMO.Excel.Pdf/ExcelPdfConverterExtensions.cs @@ -1447,7 +1447,7 @@ private static void AddRadarSeries(OfficeDrawing drawing, ExcelChartSnapshot sna double centerX = width / 2D; double centerY = height / 2D; double radius = Math.Max(36D, Math.Min(width - 52D, height - 42D) / 2D); - double max = GetPositiveMax(series); + var (min, max) = GetRadarValueRange(series); for (int ring = 1; ring <= 4; ring++) { double ringRadius = radius * ring / 4D; @@ -1474,8 +1474,8 @@ private static void AddRadarSeries(OfficeDrawing drawing, ExcelChartSnapshot sna OfficeColor color = GetChartSeriesColor(s); var points = new List(categories.Count); for (int i = 0; i < categories.Count; i++) { - double value = Math.Max(0D, GetSeriesValue(series[s], i)); - double pointRadius = radius * Math.Min(1D, value / max); + double value = GetSeriesValue(series[s], i); + double pointRadius = radius * ToRadarRadiusRatio(value, min, max); points.Add(CreateRadarPoint(i, categories.Count, centerX, centerY, pointRadius)); } @@ -1787,6 +1787,27 @@ private static (double Min, double Max) GetFiniteRange(IReadOnlyList val return any ? ExpandFlatRange(min, max) : (0D, 1D); } + private static (double Min, double Max) GetRadarValueRange(IReadOnlyList series) { + var (min, max) = GetFiniteSeriesRange(series); + min = Math.Min(0D, min); + max = Math.Max(0D, max); + return ExpandFlatRange(min, max); + } + + private static double ToRadarRadiusRatio(double value, double min, double max) { + double range = max - min; + double ratio = range <= 0D ? 0.5D : (value - min) / range; + if (ratio < 0D) { + return 0D; + } + + if (ratio > 1D) { + return 1D; + } + + return ratio; + } + private static (double Min, double Max) ExpandFlatRange(double min, double max) { if (max > min) { return (min, max); diff --git a/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs b/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs index bcf409465..0dd48829a 100644 --- a/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs +++ b/OfficeIMO.Pdf/Manipulation/PdfFormFiller.cs @@ -483,9 +483,10 @@ private static void CollectFlattenWidgets( int appearanceObjectNumber; if (isButtonField) { - if (!TryGetButtonAppearanceReference(objects, field, value, out PdfReference? appearanceReference)) { - EnsureButtonWidgetAppearances(objects, field, value ?? "Off", ref nextObjectNumber); - if (!TryGetButtonAppearanceReference(objects, field, value, out appearanceReference)) { + string appearanceState = GetButtonWidgetFlattenAppearanceState(objects, field, value); + if (!TryGetButtonAppearanceReference(objects, field, appearanceState, out PdfReference? appearanceReference)) { + EnsureButtonWidgetAppearances(objects, field, appearanceState, ref nextObjectNumber); + if (!TryGetButtonAppearanceReference(objects, field, appearanceState, out appearanceReference)) { throw new NotSupportedException(UnsupportedFlattenWidgetMessage); } } @@ -1040,6 +1041,20 @@ rect.Items[2] is not PdfNumber x2 || return width > 0D && height > 0D; } + private static string GetButtonWidgetFlattenAppearanceState(Dictionary objects, PdfDictionary widget, string? inheritedValue) { + string? widgetState = TryReadName(objects, widget, "AS"); + if (!string.IsNullOrEmpty(widgetState)) { + return widgetState!; + } + + if (!string.IsNullOrEmpty(inheritedValue) && + HasButtonNormalAppearanceState(objects, widget, inheritedValue!)) { + return inheritedValue!; + } + + return "Off"; + } + private static bool TryGetNormalAppearanceReference(Dictionary objects, PdfDictionary widget, out PdfReference? reference) { reference = null; if (!TryGetNormalAppearanceObject(objects, widget, out PdfObject? normalAppearance) || @@ -1051,7 +1066,7 @@ private static bool TryGetNormalAppearanceReference(Dictionary objects, PdfDictionary widget, string? inheritedValue, out PdfReference? reference) { + private static bool TryGetButtonAppearanceReference(Dictionary objects, PdfDictionary widget, string inheritedValue, out PdfReference? reference) { reference = null; if (!TryGetNormalAppearanceObject(objects, widget, out PdfObject? normalAppearance)) { return false; @@ -1066,9 +1081,8 @@ private static bool TryGetButtonAppearanceReference(Dictionary 0 } && - TryGetAppearanceStateReference(appearanceStates, stateName, out reference)) { + if (!string.IsNullOrEmpty(inheritedValue) && + TryGetAppearanceStateReference(appearanceStates, inheritedValue, out reference)) { return true; } diff --git a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs index 61817ed33..bb39b740c 100644 --- a/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs +++ b/OfficeIMO.Tests/Pdf/Excel.SaveAsPdf.cs @@ -2084,6 +2084,28 @@ public void SaveAsPdf_ExcelWorkbook_Exports_Radar_Chart_Snapshots() { Assert.Contains("0.184 0.435 0.243 rg", rawPdf, StringComparison.Ordinal); } + [Fact] + public void SaveAsPdf_ExcelWorkbook_Preserves_Negative_Radar_Chart_Values() { + var series = new List { + new ExcelChartSeries("Delta", new[] { -10D, -2D, 0D, 10D }, ExcelChartType.Radar) + }; + + MethodInfo rangeMethod = typeof(ExcelPdfConverterExtensions).GetMethod("GetRadarValueRange", BindingFlags.NonPublic | BindingFlags.Static)!; + object range = rangeMethod.Invoke(null, new object[] { series })!; + double min = (double)range.GetType().GetField("Item1")!.GetValue(range)!; + double max = (double)range.GetType().GetField("Item2")!.GetValue(range)!; + + MethodInfo ratioMethod = typeof(ExcelPdfConverterExtensions).GetMethod("ToRadarRadiusRatio", BindingFlags.NonPublic | BindingFlags.Static)!; + double negativeRatio = (double)ratioMethod.Invoke(null, new object[] { -2D, min, max })!; + double zeroRatio = (double)ratioMethod.Invoke(null, new object[] { 0D, min, max })!; + double positiveRatio = (double)ratioMethod.Invoke(null, new object[] { 10D, min, max })!; + + Assert.Equal(-10D, min); + Assert.Equal(10D, max); + Assert.True(negativeRatio > 0D, "Expected below-zero radar values inside the axis range to render away from the center."); + Assert.True(negativeRatio < zeroRatio && zeroRatio < positiveRatio, "Expected signed radar values to keep their axis order."); + } + [Fact] public void SaveAsPdf_ExcelWorkbook_Rejects_Invalid_Options() { Assert.Throws(() => new ExcelPdfSaveOptions { HeaderRowCount = -1 }); diff --git a/OfficeIMO.Tests/Pdf/PdfFormFillerTests.cs b/OfficeIMO.Tests/Pdf/PdfFormFillerTests.cs index f1ce54934..04ccbff89 100644 --- a/OfficeIMO.Tests/Pdf/PdfFormFillerTests.cs +++ b/OfficeIMO.Tests/Pdf/PdfFormFillerTests.cs @@ -210,6 +210,24 @@ public void FillAndFlattenFields_GeneratesMissingButtonAppearanceBeforeFlattenin Assert.Contains(" l S", appearance); } + [Fact] + public void FillAndFlattenFields_FlattensOnlySelectedRadioWidget() { + byte[] flattened = PdfFormFiller.FillAndFlattenFields(BuildRadioWidgetGroupWithoutOffAppearancePdf(), new Dictionary { + ["Payment.Method"] = "Wire" + }); + + string output = Encoding.ASCII.GetString(flattened); + string appearances = string.Concat(GetFlattenedAppearanceStreamTexts(flattened)); + + Assert.False(PdfInspector.Inspect(flattened).HasForms); + Assert.DoesNotContain("/AcroForm", output); + Assert.DoesNotContain("/Subtype /Widget", output); + Assert.Contains("Wire selected", appearances); + Assert.DoesNotContain("Card selected", appearances); + Assert.DoesNotContain("Cash selected", appearances); + Assert.DoesNotContain("1.25 w", appearances); + } + [Fact] public void FillAndFlattenFields_PaintsChoiceOptionDisplayText() { byte[] filled = PdfFormFiller.FillFields(BuildChoiceWidgetFormPdf(), new Dictionary { @@ -913,6 +931,65 @@ private static byte[] BuildMultiSelectChoiceWidgetFormPdf() { return Encoding.ASCII.GetBytes(pdf); } + private static byte[] BuildRadioWidgetGroupWithoutOffAppearancePdf() { + string cardAppearance = BuildFormStreamObject(11, "Card selected"); + string cashAppearance = BuildFormStreamObject(12, "Cash selected"); + string wireAppearance = BuildFormStreamObject(13, "Wire selected"); + string pdf = string.Join("\n", new[] { + "%PDF-1.4", + "1 0 obj", + "<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>", + "endobj", + "2 0 obj", + "<< /Type /Pages /Count 1 /Kids [3 0 R] >>", + "endobj", + "3 0 obj", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 200] /Contents 4 0 R /Annots [8 0 R 9 0 R 10 0 R] >>", + "endobj", + "4 0 obj", + "<< /Length 0 >>", + "stream", + "", + "endstream", + "endobj", + "5 0 obj", + "<< /Fields [7 0 R] >>", + "endobj", + "7 0 obj", + "<< /FT /Btn /T (Payment.Method) /Ff 49152 /V /Wire /Kids [8 0 R 9 0 R 10 0 R] >>", + "endobj", + "8 0 obj", + "<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [20 140 36 156] /F 4 /AP << /N << /Card 11 0 R >> >> >>", + "endobj", + "9 0 obj", + "<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [20 110 36 126] /F 4 /AP << /N << /Cash 12 0 R >> >> >>", + "endobj", + "10 0 obj", + "<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [20 80 36 96] /F 4 /AP << /N << /Wire 13 0 R >> >> >>", + "endobj", + cardAppearance, + cashAppearance, + wireAppearance, + "trailer", + "<< /Root 1 0 R /Size 14 >>", + "%%EOF" + }); + + return Encoding.ASCII.GetBytes(pdf); + } + + private static string BuildFormStreamObject(int objectNumber, string text) { + string content = $"BT /F1 10 Tf 0 0 Td ({text}) Tj ET"; + return string.Join("\n", new[] { + objectNumber.ToString(System.Globalization.CultureInfo.InvariantCulture) + " 0 obj", + $"<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length {content.Length} >>", + "stream", + content, + "endstream", + "endobj" + }); + } + private static byte[] BuildTextWidgetFormPdf() { string pdf = string.Join("\n", new[] { "%PDF-1.4", @@ -999,6 +1076,20 @@ indirect.Value is PdfDictionary dictionary && return Encoding.ASCII.GetString(stream.Data); } + private static IEnumerable GetFlattenedAppearanceStreamTexts(byte[] pdf) { + var (objects, _) = PdfSyntax.ParseObjects(pdf); + PdfDictionary page = Assert.IsType(objects.Values.First(indirect => + indirect.Value is PdfDictionary dictionary && + dictionary.Get("Type")?.Name == "Page").Value); + PdfDictionary resources = Assert.IsType(page.Items["Resources"]); + PdfDictionary xObjects = Assert.IsType(resources.Items["XObject"]); + foreach (PdfObject item in xObjects.Items.Values) { + PdfReference reference = Assert.IsType(item); + PdfStream stream = Assert.IsType(objects[reference.ObjectNumber].Value); + yield return Encoding.ASCII.GetString(stream.Data); + } + } + private sealed class ReadOnlyStream : MemoryStream { public override bool CanWrite => false; } diff --git a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs index f948592b4..92b2346d5 100644 --- a/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs +++ b/OfficeIMO.Tests/Pdf/Word.SaveAsPdf.PageNumbers.cs @@ -128,6 +128,51 @@ public void SaveAsPdf_OfficeIMOEngine_Keeps_NumPages_As_Document_Total_When_Sect Assert.DoesNotContain("Restartfieldfooter1/2", page3Text, StringComparison.Ordinal); } + [Fact] + public void SaveAsPdf_OfficeIMOEngine_Maps_SectionPages_Field_To_Section_Total() { + string docPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionPagesField.docx"); + string pdfPath = Path.Combine(_directoryWithFiles, "PdfNativeSectionPagesField.pdf"); + + using (WordDocument document = WordDocument.Create(docPath)) { + document.AddHeadersAndFooters(); + WordParagraph firstFooter = RequireSectionFooter(document, 0, HeaderFooterValues.Default).AddParagraph("First section "); + firstFooter.AddField(WordFieldType.Page); + firstFooter.AddText(" / "); + firstFooter.AddField(WordFieldType.SectionPages); + firstFooter.AddText(" / "); + firstFooter.AddField(WordFieldType.NumPages); + document.AddParagraph("First section first page"); + document.AddPageBreak(); + document.AddParagraph("First section second page"); + + WordSection secondSection = document.AddSection(); + secondSection.AddPageNumbering(1, NumberFormatValues.Decimal); + WordParagraph secondFooter = RequireSectionFooter(document, 1, HeaderFooterValues.Default).AddParagraph("Second section "); + secondFooter.AddField(WordFieldType.Page); + secondFooter.AddText(" / "); + secondFooter.AddField(WordFieldType.SectionPages); + secondFooter.AddText(" / "); + secondFooter.AddField(WordFieldType.NumPages); + secondSection.AddParagraph("Second section first page"); + document.AddPageBreak(); + secondSection.AddParagraph("Second section second page"); + + document.Save(); + document.SaveAsPdf(pdfPath, new PdfSaveOptions { + IncludePageNumbers = false + }); + } + + using PdfDocument pdf = PdfDocument.Open(pdfPath); + Assert.Equal(4, pdf.NumberOfPages); + string page1Text = NormalizeNativePageNumberText(pdf.GetPage(1).Text); + string page3Text = NormalizeNativePageNumberText(pdf.GetPage(3).Text); + + Assert.Contains("Firstsection1/2/4", page1Text, StringComparison.Ordinal); + Assert.Contains("Secondsection1/2/4", page3Text, StringComparison.Ordinal); + Assert.DoesNotContain("Secondsection1/4/4", page3Text, StringComparison.Ordinal); + } + [Fact] public void SaveAsPdf_OfficeIMOEngine_Maps_HeaderFooter_PageFields_To_PageTokens() { string docPath = Path.Combine(_directoryWithFiles, "PdfNativeHeaderFooterPageFields.docx"); diff --git a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs index 47319b178..62a2f219c 100644 --- a/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs +++ b/OfficeIMO.Word.Pdf/WordPdfConverterExtensions.Native.cs @@ -2079,6 +2079,12 @@ private static bool TryGetNativeHeaderFooterFieldToken(WordParagraph paragraph, return true; } + if (field?.FieldType == WordFieldType.SectionPages) { + token = "{pages}"; + style = MapNativePageNumberFieldStyle(field.Field); + return true; + } + return false; } @@ -2108,6 +2114,12 @@ private static bool TryGetNativeHeaderFooterFieldToken(string fieldCode, out str return true; } + if (string.Equals(fieldType, "SECTIONPAGES", StringComparison.OrdinalIgnoreCase)) { + token = "{pages}"; + style = MapNativePageNumberFieldStyle(trimmed); + return true; + } + return false; } diff --git a/Website/.powerforge/seo-baseline.json b/Website/.powerforge/seo-baseline.json index 7304a7c19..43c60a7d9 100644 --- a/Website/.powerforge/seo-baseline.json +++ b/Website/.powerforge/seo-baseline.json @@ -1,7 +1,7 @@ { "version": 1, - "generatedAtUtc": "2026-06-01T06:57:09.0495321Z", - "issueCount": 1910, + "generatedAtUtc": "2026-06-01T12:30:34.1639761Z", + "issueCount": 1912, "keyFormat": "sha256-12-b64url", "issueKeyHashes": [ "_2Biq2BcK_YyEIka", @@ -123,6 +123,7 @@ "1xCIKgygNg1BDGfA", "23IPuqDCraO8tR35", "273OdQnH-njzrFe1", + "2BoX1wfTcYm9KBG-", "2BpbjH3VYL6K7voN", "2dm8vFTddk7SFL0t", "2eatJGoxJNLuKEc3", @@ -656,6 +657,7 @@ "ev6QVW6HpjUWMjo2", "ew86I6u94VeLOXkP", "EWcbl36Nuh-QWjRG", + "EWIupZMV6s2AXOKH", "ewmSibhcl5svEFzQ", "ewscgyrFSSZpa7Oe", "EXcbg-lZC7_4iMdW",