diff --git a/CHANGELOG.md b/CHANGELOG.md
index a521bd6d..0e1d7444 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,64 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
### Added
+- Added interactive AcroForm generation (`config.WithAcroForm`, `Paper.SetAcroForm`,
+ `pkg/forms` field builders) with fill, flatten, and read-back via `pkg/forms.FormFiller`.
+- Added PDF/A conformance output (`config.WithPdfA`, levels 1B-4E) with XMP
+ metadata, extension schemas, and sRGB or custom output intents; Level-A
+ profiles enable tagged output automatically.
+- Added tagged (accessible) PDF output (`config.WithTaggedPDF`, `Paper.SetTagged`)
+ with structure tree, marked content, and `/Lang` (`config.WithLanguage`).
+- Added document-catalog features: viewer preferences (`config.WithViewerPreferences`),
+ page labels, file attachments, named destinations, page annotations
+ (`Paper.AddAnnotation` and friends), per-page geometry boxes/rotation,
+ explicit file IDs (`config.WithFileID`), and deterministic byte-stable builds
+ (`config.WithDeterministic`).
+- Added `pkg/reader` (parse, page remove/reorder/extract, rotate, crop, redact,
+ `reader.MergeFiles`) backed by new `merge.BytesSelected`/`merge.PageSelection`.
+- Added `pkg/sign` (CMS/PAdES digital signatures, timestamps, DSS/LTV), `pkg/svg`,
+ `pkg/barcode`, `pkg/image` (GIF/WebP/TIFF via codec normalization), `pkg/tmpl`,
+ and CMYK color support (`props.Color.CMYK`).
+- Added HTML pipeline features: CSS multi-column, grid, and position/transform
+ support, form controls, automatic fallback fonts for non-WinAnsi text
+ (`html.WithFallbackFontPath`), opt-in remote assets (`html.WithRemoteAssets`,
+ `html.WithURLPolicy`, `html.WithHTTPClient`), strict asset errors
+ (`html.WithStrictAssets`), `!important` cascade handling, and typed
+ `html.ParseError`/`html.AssetError`/`html.LimitError` surfaces.
+
+### Fixed
+
+- Fixed merged PDFs rendering blank: `merge.Bytes` now materializes inherited
+ page-tree attributes (`/MediaBox`, `/Resources`, `/Rotate`, crop boxes) onto
+ each copied page.
+- Fixed RC4-protected documents corrupting every Info string after the first
+ (per-string keystream), non-ASCII outline titles showing mojibake (UTF-16
+ encoding), page-number aliases rendering missing glyphs with UTF-8 fonts,
+ `SetAlpha` emitting an invalid blend mode for the empty string, XMP metadata
+ never being referenced from the catalog, bookmark destinations on mixed page
+ sizes, PDF dates missing timezone offsets, and a potential panic embedding
+ uncompressed Type1 fonts.
+- Fixed silent text corruption: core-font Unicode translation is now
+ case-insensitive on the family name, and characters outside cp1252 are
+ reported through `GetReport()` instead of being replaced silently.
+- Fixed text layout issues: dash-break line caching, trailing-space alignment
+ at wrap points, dotted cell borders rendering dashed, checkbox label
+ encoding and centering, CSS-correct justify (last line not stretched), and
+ `props.Text` mutation during rendering.
+- Fixed HTML/CSS fidelity: `rem`/`in` units, ` ` preservation,
+ deterministic inline shorthand expansion, `line-height` with units or
+ percentages, `border` shorthand color functions and width keywords,
+ `font-size` percentages/keywords, modern color syntax (`rgb(255 0 0 / .5)`,
+ hue units), `
` ordering, body-level inline grouping,
+ ``, CSS hex escapes in `content` strings, em-valued heights
+ and border widths, and malformed box shorthands being dropped instead of
+ zeroed.
+
+### Security
+
+- Upgraded `golang.org/x/image` to v0.43.0 (fixes GO-2026-5032, GO-2026-4961,
+ GO-2026-5066, GO-2026-5062 and related decode panics) and pinned the Go
+ toolchain to 1.26.5.
+
- Added richer HTML/CSS rendering support for inline boxes, shadows, pseudo-element
image content, flex/table layout, page-break controls, and typography details
including semibold, line-height, letter-spacing, vertical-align, and `nowrap`.
diff --git a/annotations.go b/annotations.go
new file mode 100644
index 00000000..d3b9a3c9
--- /dev/null
+++ b/annotations.go
@@ -0,0 +1,108 @@
+package paper
+
+import "github.com/avdoseferovic/paper/pkg/core/entity"
+
+// AddAnnotation adds a page annotation to the generated PDF.
+func (m *Paper) AddAnnotation(annotation entity.PageAnnotation) {
+ m.config.Annotations = append(m.config.Annotations, entity.CloneAnnotations([]entity.PageAnnotation{annotation})...)
+}
+
+// AddLinkAnnotation adds an external URI link annotation to a generated page.
+func (m *Paper) AddLinkAnnotation(pageIndex int, rect [4]float64, uri string) {
+ m.AddAnnotation(entity.PageAnnotation{
+ PageIndex: pageIndex,
+ Type: entity.AnnotationLink,
+ Rect: rect,
+ URI: uri,
+ })
+}
+
+// AddInternalLinkAnnotation adds an internal named-destination link annotation.
+func (m *Paper) AddInternalLinkAnnotation(pageIndex int, rect [4]float64, destName string) {
+ m.AddAnnotation(entity.PageAnnotation{
+ PageIndex: pageIndex,
+ Type: entity.AnnotationLink,
+ Rect: rect,
+ DestName: destName,
+ })
+}
+
+// AddPageLinkAnnotation adds an internal link annotation to a zero-based page index.
+func (m *Paper) AddPageLinkAnnotation(pageIndex int, rect [4]float64, destPage int) {
+ m.AddAnnotation(entity.PageAnnotation{
+ PageIndex: pageIndex,
+ Type: entity.AnnotationLink,
+ Rect: rect,
+ DestPage: &destPage,
+ })
+}
+
+// AddTextAnnotation adds a sticky-note annotation to a generated page.
+func (m *Paper) AddTextAnnotation(pageIndex int, rect [4]float64, text, icon string) {
+ m.AddAnnotation(entity.PageAnnotation{
+ PageIndex: pageIndex,
+ Type: entity.AnnotationText,
+ Rect: rect,
+ Contents: text,
+ Icon: icon,
+ })
+}
+
+// AddTextAnnotationOpen adds an initially open sticky-note annotation.
+func (m *Paper) AddTextAnnotationOpen(pageIndex int, rect [4]float64, text, icon string) {
+ m.AddAnnotation(entity.PageAnnotation{
+ PageIndex: pageIndex,
+ Type: entity.AnnotationText,
+ Rect: rect,
+ Contents: text,
+ Icon: icon,
+ Open: true,
+ })
+}
+
+// AddHighlight adds a highlight text-markup annotation.
+func (m *Paper) AddHighlight(pageIndex int, rect [4]float64, color [3]float64, quadPoints [][8]float64) {
+ m.AddTextMarkup(pageIndex, entity.MarkupHighlight, rect, color, quadPoints)
+}
+
+// AddUnderline adds an underline text-markup annotation.
+func (m *Paper) AddUnderline(pageIndex int, rect [4]float64, color [3]float64, quadPoints [][8]float64) {
+ m.AddTextMarkup(pageIndex, entity.MarkupUnderline, rect, color, quadPoints)
+}
+
+// AddSquiggly adds a squiggly text-markup annotation.
+func (m *Paper) AddSquiggly(pageIndex int, rect [4]float64, color [3]float64, quadPoints [][8]float64) {
+ m.AddTextMarkup(pageIndex, entity.MarkupSquiggly, rect, color, quadPoints)
+}
+
+// AddStrikeOut adds a strikeout text-markup annotation.
+func (m *Paper) AddStrikeOut(pageIndex int, rect [4]float64, color [3]float64, quadPoints [][8]float64) {
+ m.AddTextMarkup(pageIndex, entity.MarkupStrikeOut, rect, color, quadPoints)
+}
+
+// AddTextMarkup adds a text-markup annotation to a generated page.
+func (m *Paper) AddTextMarkup(
+ pageIndex int,
+ markupType entity.MarkupType,
+ rect [4]float64,
+ color [3]float64,
+ quadPoints [][8]float64,
+) {
+ types := [...]entity.AnnotationType{
+ entity.AnnotationHighlight,
+ entity.AnnotationUnderline,
+ entity.AnnotationSquiggly,
+ entity.AnnotationStrikeOut,
+ }
+ annotationType := entity.AnnotationHighlight
+ if markupType >= 0 && int(markupType) < len(types) {
+ annotationType = types[markupType]
+ }
+ m.AddAnnotation(entity.PageAnnotation{
+ PageIndex: pageIndex,
+ Type: annotationType,
+ Rect: rect,
+ Color: &color,
+ QuadPoints: append([][8]float64(nil), quadPoints...),
+ })
+}
diff --git a/codecov.yml b/codecov.yml
index 9a35a6f5..c2df0ad4 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -2,6 +2,17 @@ coverage:
precision: 2
round: up
range: "70...100"
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 1%
+ # Patch status is informational: it still reports diff coverage on the PR
+ # but does not block merges; the project status above keeps overall
+ # coverage enforced.
+ patch:
+ default:
+ informational: true
ignore:
- ".idea"
diff --git a/docs/features/annotations.md b/docs/features/annotations.md
new file mode 100644
index 00000000..e8953e77
--- /dev/null
+++ b/docs/features/annotations.md
@@ -0,0 +1,56 @@
+# Annotations
+
+Paper can emit PDF annotations on generated pages by page index. This covers external links, internal named-destination links, sticky notes, and text markup annotations.
+
+## Links
+
+```go
+m := paper.New(config.NewBuilder().Build())
+m.AddLinkAnnotation(0, [4]float64{40, 50, 120, 70}, "https://example.com")
+m.AddInternalLinkAnnotation(0, [4]float64{40, 80, 120, 100}, "section")
+m.AddPageLinkAnnotation(0, [4]float64{40, 110, 120, 130}, 1)
+```
+
+`AddInternalLinkAnnotation` targets a named destination created with `AddNamedDest` or `WithNamedDestinations`.
+`AddPageLinkAnnotation` targets a zero-based page index directly.
+
+## Sticky Notes
+
+```go
+m.AddTextAnnotation(0, [4]float64{10, 20, 30, 40}, "Review this section", "Comment")
+m.AddTextAnnotationOpen(0, [4]float64{35, 20, 55, 40}, "Open note", "Note")
+```
+
+An empty icon defaults to `Note`.
+
+## Text Markup
+
+```go
+m.AddHighlight(0, [4]float64{90, 100, 140, 120}, [3]float64{1, 1, 0}, nil)
+m.AddUnderline(0, [4]float64{90, 130, 140, 150}, [3]float64{1, 0, 0}, nil)
+m.AddSquiggly(0, [4]float64{90, 160, 140, 180}, [3]float64{0, 0, 1}, nil)
+m.AddStrikeOut(0, [4]float64{90, 190, 140, 210}, [3]float64{1, 0, 0}, nil)
+```
+
+Pass custom quad points when the markup spans multiple text regions.
+
+## Builder API
+
+```go
+cfg := config.NewBuilder().
+ WithAnnotations(entity.PageAnnotation{
+ PageIndex: 0,
+ Type: entity.AnnotationText,
+ Rect: [4]float64{10, 20, 30, 40},
+ Contents: "Generated by Paper",
+ Icon: "Comment",
+ }).
+ Build()
+```
+
+## Notes
+
+- `PageIndex` is zero-based.
+- Annotation rectangles and quad points use the generated PDF backend coordinate space.
+- Annotations are document-level page entries, so Paper keeps generation on the whole-document path even if concurrent generation was requested.
+- This feature emits annotations in newly generated PDFs. It does not yet read, import, flatten, or redact annotations from existing PDFs.
diff --git a/docs/features/attachments.md b/docs/features/attachments.md
new file mode 100644
index 00000000..aa47eacf
--- /dev/null
+++ b/docs/features/attachments.md
@@ -0,0 +1,42 @@
+# Attachments
+
+Paper can embed files in generated PDFs through the document catalog. Attachments are stored as embedded file streams, referenced from `/Names /EmbeddedFiles`, and listed in the catalog `/AF` associated-files array.
+
+## Builder API
+
+```go
+cfg := config.NewBuilder().
+ WithFileAttachments(entity.FileAttachment{
+ FileName: "invoice.xml",
+ MIMEType: "application/xml",
+ Description: "Invoice XML",
+ AFRelationship: "Data",
+ Data: []byte(""),
+ }).
+ Build()
+```
+
+## Runtime API
+
+```go
+m := paper.New(config.NewBuilder().Build())
+m.AttachFile(entity.FileAttachment{
+ FileName: "report.json",
+ MIMEType: "application/json",
+ AFRelationship: "Supplement",
+ Data: payload,
+})
+```
+
+`AFRelationship` defaults to `Unspecified`, and `MIMEType` defaults to `application/octet-stream`.
+
+For Factur-X/ZUGFeRD-style hybrid invoices, use PDF/A-3B or another
+attachment-capable PDF/A profile and set `AFRelationship: "Alternative"` on
+the XML attachment. Pair it with `entity.PdfAConfig.XMPSchemas` and
+`XMPProperties` to declare the invoice metadata namespace in the PDF/A XMP
+packet.
+
+## Notes
+
+- Attachments are document-level catalog entries, so Paper keeps generation on the whole-document path even if concurrent generation was requested.
+- This feature embeds files in newly generated PDFs. It does not yet add PDF/A validation, attachment extraction, or reader/import support for existing PDFs.
diff --git a/docs/features/destinations.md b/docs/features/destinations.md
new file mode 100644
index 00000000..b95c024c
--- /dev/null
+++ b/docs/features/destinations.md
@@ -0,0 +1,40 @@
+# Named Destinations
+
+Named destinations add stable destination names to the generated PDF catalog. Other PDF content or external tools can target these names without relying on physical page labels.
+
+## Builder API
+
+```go
+cfg := config.NewBuilder().
+ WithNamedDestinations(
+ entity.NamedDestination{Name: "top", PageIndex: 0},
+ entity.NamedDestination{
+ Name: "section",
+ PageIndex: 2,
+ FitType: entity.DestinationFitH,
+ Top: 700,
+ },
+ ).
+ Build()
+```
+
+## Runtime API
+
+```go
+m := paper.New(config.NewBuilder().Build())
+m.AddNamedDest(entity.NamedDestination{
+ Name: "detail",
+ PageIndex: 0,
+ FitType: entity.DestinationXYZ,
+ Left: 10,
+ Top: 20,
+ Zoom: 1.5,
+})
+```
+
+`PageIndex` is zero-based. `FitType` defaults to `entity.DestinationFit`; supported values are `DestinationFit`, `DestinationFitH`, and `DestinationXYZ`.
+
+## Notes
+
+- Named destinations are document-level catalog entries, so Paper keeps generation on the whole-document path even if concurrent generation was requested.
+- Invalid page indexes and empty destination names are skipped.
diff --git a/docs/features/fileid.md b/docs/features/fileid.md
new file mode 100644
index 00000000..ae0856b4
--- /dev/null
+++ b/docs/features/fileid.md
@@ -0,0 +1,39 @@
+# File ID
+
+Paper can write an explicit PDF trailer file identifier through `/ID`. This is useful when generated output needs a stable document identifier for downstream workflows.
+
+## Builder API
+
+```go
+cfg := config.NewBuilder().
+ WithFileID([]byte{0xab, 0xcd, 0xef}).
+ Build()
+```
+
+## Runtime API
+
+```go
+m := paper.New(config.NewBuilder().Build())
+m.SetFileID([]byte{0xab, 0xcd, 0xef})
+```
+
+The identifier bytes are written as the first and second entries of the trailer `/ID` array.
+
+## Deterministic Output
+
+```go
+cfg := config.NewBuilder().
+ WithDeterministic(true).
+ Build()
+
+m := paper.New(cfg)
+m.SetDeterministic(true)
+```
+
+Deterministic mode derives a stable trailer `/ID` for generated PDFs that do not already have an explicit file ID. It also replaces implicit wall-clock creation, modification, and attachment dates with the zero time so identical inputs produce identical output.
+
+## Notes
+
+- Paper clones the identifier bytes at builder and runtime boundaries.
+- When AES-128 protection is enabled after setting a file ID, the identifier is also used by the protection key setup.
+- Protected PDFs keep the security handler's encryption-specific identifier behavior.
diff --git a/docs/features/forms.md b/docs/features/forms.md
new file mode 100644
index 00000000..13d67acb
--- /dev/null
+++ b/docs/features/forms.md
@@ -0,0 +1,87 @@
+# AcroForms
+
+`pkg/forms` creates interactive PDF form fields in generated documents. These
+are PDF AcroForm fields, not just visual components: viewers can edit text
+fields, toggle buttons, choose list values, and prepare unsigned signature
+fields.
+
+```go
+form := forms.NewAcroForm().
+ Add(forms.NewTextField("name", [4]float64{72, 700, 300, 720}, 0).SetValue("Ada")).
+ Add(forms.NewCheckbox("agree", [4]float64{72, 670, 92, 690}, 0, false)).
+ Add(forms.NewDropdown("role", [4]float64{72, 640, 250, 660}, 0,
+ []string{"Developer", "Designer", "Manager"}))
+
+doc := paper.New(config.NewBuilder().WithAcroForm(form).Build())
+doc.AddAutoRow(col.New(12).Add(text.New("Interactive form")))
+pdf, err := doc.Generate(context.Background())
+```
+
+Fields use PDF coordinates in points with page indexes starting at zero. For
+runtime configuration, call `(*Paper).SetAcroForm(form)` before `Generate`.
+
+Supported fields:
+
+- `NewTextField`, `NewMultilineTextField`, `NewPasswordField`
+- `NewCheckbox`
+- `NewRadioGroup`
+- `NewDropdown`, `NewListBox`
+- `NewSignatureField` for unsigned signature widgets
+
+## Filling Existing Forms
+
+`NewFormFiller` works with a parsed `pkg/reader` PDF and appends incremental
+updates for changed field values:
+
+```go
+r, err := reader.Load("form.pdf")
+if err != nil {
+ return err
+}
+
+filler := forms.NewFormFiller(r)
+names, err := filler.FieldNames()
+if err != nil {
+ return err
+}
+
+err = filler.SetValue("name", "Grace Hopper")
+if err != nil {
+ return err
+}
+err = filler.SetCheckbox("agree", true)
+if err != nil {
+ return err
+}
+
+err = filler.SaveTo("filled.pdf")
+```
+
+Filled forms can also be flattened into ordinary page content for supported
+field types:
+
+```go
+err = filler.Flatten()
+if err != nil {
+ return err
+}
+err = filler.SaveTo("flattened.pdf")
+```
+
+Current form filling scope:
+
+- `FieldNames`
+- `GetValue`
+- `SetValue` for text and choice fields
+- `SetCheckbox`
+- `Flatten` for simple text, choice, and checkbox fields in classic
+ Paper-style AcroForms
+- `Bytes` and `SaveTo`
+- incremental field-object updates that preserve the original PDF revision
+- flattening rewrites a fresh classic-xref PDF without `/AcroForm` or widget
+ annotations
+
+Limitations: full appearance-stream flattening, radio appearance rendering,
+signature widgets, and arbitrary third-party form appearance regeneration are
+not implemented yet. Complex xref-stream/object-stream PDFs depend on future
+reader support.
diff --git a/docs/features/pagegeometry.md b/docs/features/pagegeometry.md
new file mode 100644
index 00000000..3229d23c
--- /dev/null
+++ b/docs/features/pagegeometry.md
@@ -0,0 +1,33 @@
+# Page Geometry
+
+Paper can write page dictionary geometry entries for generated PDFs: rotation plus CropBox, BleedBox, TrimBox, and ArtBox.
+
+## Runtime API
+
+```go
+m := paper.New(config.NewBuilder().Build())
+m.SetPageRotation(0, 90)
+m.SetCropBox(0, [4]float64{10, 20, 210, 290})
+m.SetTrimBox(0, [4]float64{15, 25, 205, 285})
+```
+
+`PageIndex` is zero-based. Rotation must be a multiple of 90 degrees.
+
+## Builder API
+
+```go
+crop := [4]float64{10, 20, 210, 290}
+cfg := config.NewBuilder().
+ WithPageGeometries(entity.PageGeometry{
+ PageIndex: 0,
+ Rotate: 90,
+ CropBox: &crop,
+ }).
+ Build()
+```
+
+## Notes
+
+- Box values are written directly to the generated PDF page dictionary.
+- Page geometry entries are document-level page dictionary settings, so Paper keeps generation on the whole-document path even if concurrent generation was requested.
+- This feature applies to newly generated Paper PDFs. It does not inspect or rewrite page geometry from existing PDFs.
diff --git a/docs/features/pdfa.md b/docs/features/pdfa.md
new file mode 100644
index 00000000..b942454b
--- /dev/null
+++ b/docs/features/pdfa.md
@@ -0,0 +1,78 @@
+# PDF/A
+
+Paper can emit PDF/A identification foundations for generated PDFs:
+
+```go
+cfg := config.NewBuilder().
+ WithTitle("Archive Copy", false).
+ WithPdfA(entity.PdfAConfig{Level: entity.PdfA2B}).
+ Build()
+
+doc := paper.New(cfg)
+```
+
+Runtime configuration is also available:
+
+```go
+doc.SetPdfA(entity.PdfAConfig{Level: entity.PdfA3B})
+```
+
+Supported levels:
+
+- `entity.PdfA1B`, `PdfA1A`
+- `entity.PdfA2B`, `PdfA2U`, `PdfA2A`
+- `entity.PdfA3B`, `PdfA3A`
+- `entity.PdfA4`, `PdfA4F`, `PdfA4E`
+
+Paper writes PDF/A XMP identification metadata, references that metadata from
+the catalog, emits an sRGB ICC output intent by default, selects the PDF
+version implied by the level, and enables tagged PDF automatically for Level A
+profiles.
+
+## Custom XMP Extensions
+
+`entity.PdfAConfig` can declare PDF/A extension schemas and write namespaced
+property values into the XMP packet. This is useful for hybrid document
+profiles such as Factur-X/ZUGFeRD, where a PDF/A-3B document embeds an XML
+invoice attachment and declares a profile-specific XMP namespace:
+
+```go
+cfg := config.NewBuilder().
+ WithPdfA(entity.PdfAConfig{
+ Level: entity.PdfA3B,
+ XMPSchemas: []entity.XMPSchema{{
+ Schema: "Factur-X PDFA Extension Schema",
+ NamespaceURI: "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#",
+ Prefix: "fx",
+ Properties: []entity.XMPSchemaProperty{{
+ Name: "DocumentFileName",
+ ValueType: "Text",
+ Category: "external",
+ Description: "Name of the embedded XML invoice file",
+ }},
+ }},
+ XMPProperties: []entity.XMPPropertyBlock{{
+ Namespace: "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#",
+ Prefix: "fx",
+ Properties: []entity.XMPProperty{
+ {Name: "DocumentFileName", Value: "factur-x.xml"},
+ {Name: "DocumentType", Value: "INVOICE"},
+ {Name: "Version", Value: "1.0"},
+ {Name: "ConformanceLevel", Value: "BASIC"},
+ },
+ }},
+ }).
+ WithFileAttachments(entity.FileAttachment{
+ FileName: "factur-x.xml",
+ MIMEType: "application/xml",
+ AFRelationship: "Alternative",
+ Data: invoiceXML,
+ }).
+ Build()
+```
+
+For PDF/A-3 and PDF/A-4 profiles that allow associated files, Paper also
+declares the built-in PDF/A associated-file (`pdfaf`) extension schema.
+
+Current scope: strict PDF/A validation, guaranteed validator conformance, and
+embedded-font enforcement are not implemented yet.
diff --git a/docs/features/reader.md b/docs/features/reader.md
new file mode 100644
index 00000000..6ad5c556
--- /dev/null
+++ b/docs/features/reader.md
@@ -0,0 +1,110 @@
+# Reader
+
+`pkg/reader` provides a foundation for working with existing PDFs:
+
+```go
+r, err := reader.Load("document.pdf")
+if err != nil {
+ return err
+}
+
+fmt.Println("pages:", r.PageCount())
+
+page, err := r.Page(0)
+if err != nil {
+ return err
+}
+
+text, err := page.ExtractText()
+if err != nil {
+ return err
+}
+fmt.Println(text)
+```
+
+The package can also merge parsed readers through Paper's existing PDF merge
+engine and extract selected pages into a new PDF:
+
+```go
+first, _ := reader.Load("a.pdf")
+second, _ := reader.Load("b.pdf")
+
+merged, err := reader.Merge(first, second)
+if err != nil {
+ return err
+}
+
+err = merged.SaveTo("merged.pdf")
+
+subset, err := reader.ExtractPages(first, 2, 0)
+if err != nil {
+ return err
+}
+err = subset.SaveTo("subset.pdf")
+
+err = merged.RemovePage(0)
+if err != nil {
+ return err
+}
+err = merged.RotatePage(0, 90)
+if err != nil {
+ return err
+}
+err = merged.CropPage(0, [4]float64{36, 36, 576, 756})
+if err != nil {
+ return err
+}
+err = merged.AddBlankPage(612, 792)
+```
+
+## Redaction Foundation
+
+`RedactText` and `RedactPattern` permanently remove matching bytes from
+uncompressed page content streams while preserving stream length and xref
+offsets:
+
+```go
+r, _ := reader.Load("document.pdf")
+redacted, err := reader.RedactText(r, []string{"John Doe", "555-12-3456"}, nil)
+if err != nil {
+ return err
+}
+err = redacted.SaveTo("redacted.pdf")
+```
+
+Regex redaction is also available:
+
+```go
+ssn := regexp.MustCompile(`\d{3}-\d{2}-\d{4}`)
+redacted, err := reader.RedactPattern(r, ssn, nil)
+```
+
+Current scope:
+
+- `Load`, `Parse`, and `ParseWithOptions`
+- `RawBytes`, `Version`, `PageCount`, and zero-based `Page`
+- page `MediaBox`, `CropBox`, `BleedBox`, `TrimBox`, `ArtBox`, rotation,
+ width, and height metadata
+- decoded page `ContentStream` for unfiltered and `/FlateDecode` streams
+- basic text extraction from literal and hex strings used with `Tj`, `TJ`,
+ `'`, and `"` operators
+- `Merge` and `MergeFiles` convenience wrappers
+- `ExtractPages` for zero-based page selection/reordering backed by the merge
+ engine
+- `Modifier` page operations: `RemovePage`, `ReorderPages`, `RotatePage`,
+ `CropPage`, and `AddBlankPage`
+- byte-preserving `RedactText` and `RedactPattern` for uncompressed/simple
+ content streams
+
+Limitations:
+
+- encrypted PDFs are rejected
+- xref streams and object streams are not yet parsed
+- full font CMap Unicode extraction is not implemented
+- compressed content streams are rejected by the redaction foundation
+- redaction overlays and glyph-bounding-box matching are not implemented yet
+- Folio-style object/resource page import and modifier-level form flattening
+ are not part of this reader foundation slice; use `forms.FormFiller.Flatten`
+ for supported simple AcroForm flattening
+- `RotatePage` and `CropPage` rewrite classic-xref PDFs with updated page
+ dictionaries; full xref-stream/object-stream modifier support is future work
diff --git a/docs/features/signing.md b/docs/features/signing.md
new file mode 100644
index 00000000..8bd88bc9
--- /dev/null
+++ b/docs/features/signing.md
@@ -0,0 +1,85 @@
+# Digital Signing
+
+`pkg/sign` signs existing PDFs by appending an incremental update. The original
+document bytes remain intact and the new revision adds a signature dictionary,
+signature field, AcroForm entry, byte range, and detached CMS signature:
+
+```go
+signer, err := sign.NewLocalSigner(privateKey, []*x509.Certificate{cert})
+if err != nil {
+ return err
+}
+
+signed, err := sign.SignPDF(pdfBytes, sign.Options{
+ Signer: signer,
+ Level: sign.LevelBB,
+ Name: "Ada Lovelace",
+ Reason: "Approved",
+ Location: "London",
+ SigningTime: time.Now(),
+})
+if err != nil {
+ return err
+}
+```
+
+For PAdES B-T, provide an RFC 3161 timestamp authority client:
+
+```go
+signed, err := sign.SignPDF(pdfBytes, sign.Options{
+ Signer: signer,
+ Level: sign.LevelBT,
+ TSAClient: sign.NewTSAClient("https://tsa.example.com"),
+})
+```
+
+For PAdES B-LT and B-LTA, Paper embeds a Document Security Store (DSS) with
+the signer's certificate chain, optional OCSP responses, CRLs, and extra
+certificates. B-LTA appends a document timestamp after the DSS update:
+
+```go
+signed, err := sign.SignPDF(pdfBytes, sign.Options{
+ Signer: signer,
+ Level: sign.LevelBLTA,
+ TSAClient: sign.NewTSAClient("https://tsa.example.com"),
+ OCSPClient: sign.NewOCSPClient(),
+ CRLs: [][]byte{crlDER},
+ ExtraCerts: [][]byte{intermediateDER},
+})
+```
+
+Applications that keep private keys outside the process can provide an
+external signing callback:
+
+```go
+signer, err := sign.NewExternalSigner(
+ func(digest []byte) ([]byte, error) {
+ return hsm.Sign(digest)
+ },
+ []*x509.Certificate{cert},
+ sign.SHA256WithRSA,
+)
+```
+
+Current scope:
+
+- `Signer`, `LocalSigner`, and `ExternalSigner`
+- RSA and ECDSA SHA-2 algorithms: SHA-256, SHA-384, and SHA-512
+- algorithm hash, digest OID, and signature OID metadata
+- `BuildDetachedCMS` for detached CMS SignedData generation with signing-time
+ and message-digest signed attributes
+- `SignPDF` incremental signing for classic-xref PDFs supported by Paper's
+ reader foundation
+- PAdES B-B signatures using `/SubFilter /ETSI.CAdES.detached`
+- PAdES B-T timestamp token embedding through `TSAClient`
+- PAdES B-LT DSS/VRI validation data through `NewDSS`, `AddDSS`,
+ `AddSignatureValidation`, OCSP responses, CRLs, and extra certificates
+- PAdES B-LTA document timestamps through `AddDocumentTimestamp`
+
+Limitations:
+
+- xref streams, object streams, and encrypted PDFs are not supported by this
+ signing path yet
+- visible signature appearances are not implemented
+- PKCS#12 parsing is still future work; use `NewLocalSigner` with a pre-parsed
+ key and certificate chain
diff --git a/docs/features/svg.md b/docs/features/svg.md
new file mode 100644
index 00000000..a43cfda9
--- /dev/null
+++ b/docs/features/svg.md
@@ -0,0 +1,41 @@
+# SVG
+
+`pkg/svg` parses SVG markup and exposes the document dimensions, root node,
+`viewBox`, reusable `defs`, and `preserveAspectRatio` setting:
+
+```go
+doc, err := svg.Parse(``)
+if err != nil {
+ return err
+}
+
+fmt.Println(doc.Width(), doc.Height(), doc.ViewBox())
+```
+
+The parsed SVG can be rasterized to PNG bytes:
+
+```go
+pngBytes, pxW, pxH, err := doc.Rasterize(40, 0)
+```
+
+It can also be adapted directly to Paper image components. Paper keeps the
+original SVG bytes and rasterizes them during PDF image embedding:
+
+```go
+row := doc.AutoRow(props.Rect{Percent: 80, Center: true})
+pdf.AddRows(row)
+```
+
+Current scope:
+
+- `Parse`, `ParseBytes`, and `ParseReader`
+- root node and child tree access
+- `Defs` indexed by element `id`
+- `Width`, `Height`, `ViewBox`, `AspectRatio`, and `PreserveAspectRatio`
+- package-level and document-level `Rasterize`
+- `Component`, `Col`, `Row`, and `AutoRow` adapters
+
+Limitation: SVG output is currently rasterized to PNG before PDF embedding.
+Folio-style native vector SVG drawing is still future work.
diff --git a/docs/features/taggedpdf.md b/docs/features/taggedpdf.md
new file mode 100644
index 00000000..c1244483
--- /dev/null
+++ b/docs/features/taggedpdf.md
@@ -0,0 +1,23 @@
+# Tagged PDF
+
+Tagged PDF output can be enabled for generated documents:
+
+```go
+doc := paper.New(config.NewBuilder().
+ WithTaggedPDF(true).
+ Build())
+```
+
+or at runtime:
+
+```go
+doc.SetTagged(true)
+```
+
+When enabled, Paper emits the tagged-PDF foundation required by accessibility
+tools: catalog `/MarkInfo`, a `/StructTreeRoot`, a parent tree, per-page
+`/StructParents`, and page-level BDC/EMC marked content with MCIDs.
+
+Current scope: Paper tags each generated page as one paragraph structure
+element. Per-component semantic tags such as headings, tables, figures, and
+links are not inferred yet.
diff --git a/docs/features/viewer.md b/docs/features/viewer.md
new file mode 100644
index 00000000..71c08411
--- /dev/null
+++ b/docs/features/viewer.md
@@ -0,0 +1,54 @@
+# Viewer Preferences
+
+Viewer preferences are PDF catalog hints for how a reader should open the document. They do not change page content, and PDF viewers may ignore some hints.
+
+## Page layout and mode
+
+```go
+cfg := config.NewBuilder().
+ WithViewerPreferences(entity.ViewerPreferences{
+ PageLayout: entity.LayoutTwoPageRight,
+ PageMode: entity.ModeUseThumbs,
+ DisplayDocTitle: true,
+ }).
+ Build()
+```
+
+## Window hints
+
+`entity.ViewerPreferences` supports `HideToolbar`, `HideMenubar`, `HideWindowUI`, `FitWindow`, `CenterWindow`, and `DisplayDocTitle`.
+
+## Initial view
+
+Use `OpenPage` and `OpenZoom` to suggest the page and zoom used when the PDF opens.
+
+```go
+cfg := config.NewBuilder().
+ WithViewerPreferences(entity.ViewerPreferences{
+ OpenPage: 0,
+ OpenZoom: "FitH",
+ }).
+ Build()
+```
+
+`OpenPage` is zero-based. `OpenZoom` supports `Fit`, `FitH`, `FitV`, `FitB`, or a percentage such as `100` or `125%`. If only unrelated viewer preferences are set, Paper does not emit an `/OpenAction` from the zero-value `OpenPage`.
+
+## Page labels
+
+Page labels define the labels shown by PDF viewers instead of plain physical page numbers.
+
+```go
+cfg := config.NewBuilder().
+ WithPageLabels(
+ entity.PageLabelRange{PageIndex: 0, Style: entity.LabelRomanLower, Prefix: "front-"},
+ entity.PageLabelRange{PageIndex: 4, Style: entity.LabelDecimal, Start: 1},
+ ).
+ Build()
+```
+
+`PageIndex` is zero-based. `Style` can be decimal, Roman, alphabetic, or none. `Prefix` is prepended to the generated number, and `Start` sets the first number for that range.
+
+## Notes
+
+- Catalog options are document-level settings, so Paper keeps generation on the whole-document path even if concurrent generation was requested.
+- These settings are advisory. They rely on PDF viewer support and do not affect the rendered page content.
diff --git a/docs/go.mod b/docs/go.mod
index 58bb53d9..71bb2863 100644
--- a/docs/go.mod
+++ b/docs/go.mod
@@ -2,7 +2,7 @@ module github.com/avdoseferovic/paper/docs
go 1.26.4
-require github.com/avdoseferovic/paper v0.1.0
+require github.com/avdoseferovic/paper v0.2.1
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
diff --git a/docs/go.sum b/docs/go.sum
index f0b02d7f..7b9cf604 100644
--- a/docs/go.sum
+++ b/docs/go.sum
@@ -1,7 +1,7 @@
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
-github.com/avdoseferovic/paper v0.1.0 h1:L0CdE2TmhJNX08OvLzEBp+wIypkXrsknSeYgivBRmTE=
-github.com/avdoseferovic/paper v0.1.0/go.mod h1:FEuz10GuLYuGs2jUeR6xmU5wDBaXx5Hbepr8yO8F+mg=
+github.com/avdoseferovic/paper v0.2.1 h1:S/0OJYEHw7BGXrT7glUenOWsqL7/RnbzBDAqAkDfZpQ=
+github.com/avdoseferovic/paper v0.2.1/go.mod h1:FEuz10GuLYuGs2jUeR6xmU5wDBaXx5Hbepr8yO8F+mg=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
diff --git a/document_features.go b/document_features.go
new file mode 100644
index 00000000..9cf14d5c
--- /dev/null
+++ b/document_features.go
@@ -0,0 +1,57 @@
+package paper
+
+import "github.com/avdoseferovic/paper/pkg/core/entity"
+
+// SetAcroForm attaches interactive form fields to the generated PDF. The
+// form is cloned so later mutations of the input do not leak into the
+// document.
+func (m *Paper) SetAcroForm(form *entity.AcroForm) {
+ m.config.AcroForm = entity.CloneAcroForm(form)
+}
+
+// SetPdfA configures PDF/A conformance metadata and output intent at runtime.
+func (m *Paper) SetPdfA(config entity.PdfAConfig) {
+ m.config.PdfA = entity.ClonePdfAConfig(&config)
+}
+
+// SetTagged enables or disables tagged (accessible) PDF output at runtime.
+func (m *Paper) SetTagged(enabled bool) {
+ m.config.TaggedPDF = enabled
+}
+
+// SetLanguage sets the document language written to the catalog /Lang entry.
+func (m *Paper) SetLanguage(language string) {
+ m.config.Language = language
+}
+
+// SetViewerPreferences configures catalog viewer hints at runtime.
+func (m *Paper) SetViewerPreferences(prefs entity.ViewerPreferences) {
+ clone := prefs
+ m.config.ViewerPreferences = &clone
+}
+
+// SetPageLabels defines the labels viewers show instead of physical page
+// numbers.
+func (m *Paper) SetPageLabels(labels ...entity.PageLabelRange) {
+ m.config.PageLabels = append([]entity.PageLabelRange(nil), labels...)
+}
+
+// AddAttachment embeds a file in the generated PDF.
+func (m *Paper) AddAttachment(attachment entity.FileAttachment) {
+ m.config.Attachments = append(m.config.Attachments, entity.CloneAttachments([]entity.FileAttachment{attachment})...)
+}
+
+// AddNamedDestination defines a named location in the generated PDF.
+func (m *Paper) AddNamedDestination(destination entity.NamedDestination) {
+ m.config.NamedDestinations = append(m.config.NamedDestinations, destination)
+}
+
+// SetFileID sets an explicit trailer /ID for the generated PDF.
+func (m *Paper) SetFileID(id []byte) {
+ m.config.FileID = append([]byte(nil), id...)
+}
+
+// SetDeterministic makes repeated builds of the same document byte-identical.
+func (m *Paper) SetDeterministic(deterministic bool) {
+ m.config.Deterministic = deterministic
+}
diff --git a/examples/go.mod b/examples/go.mod
index 1a563b23..0d8ed033 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -2,7 +2,7 @@ module github.com/avdoseferovic/paper/examples
go 1.26.4
-require github.com/avdoseferovic/paper v0.1.0
+require github.com/avdoseferovic/paper v0.2.1
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
diff --git a/examples/go.sum b/examples/go.sum
index f0b02d7f..7b9cf604 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -1,7 +1,7 @@
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
-github.com/avdoseferovic/paper v0.1.0 h1:L0CdE2TmhJNX08OvLzEBp+wIypkXrsknSeYgivBRmTE=
-github.com/avdoseferovic/paper v0.1.0/go.mod h1:FEuz10GuLYuGs2jUeR6xmU5wDBaXx5Hbepr8yO8F+mg=
+github.com/avdoseferovic/paper v0.2.1 h1:S/0OJYEHw7BGXrT7glUenOWsqL7/RnbzBDAqAkDfZpQ=
+github.com/avdoseferovic/paper v0.2.1/go.mod h1:FEuz10GuLYuGs2jUeR6xmU5wDBaXx5Hbepr8yO8F+mg=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
diff --git a/generation.go b/generation.go
index f40c0ce0..20e1fbd2 100644
--- a/generation.go
+++ b/generation.go
@@ -40,7 +40,10 @@ func (m *Paper) generateDocument(ctx context.Context) (*core.Pdf, error) {
return nil, err
}
- if m.config.Protection != nil {
+ // Protection and document-catalog features (forms, PDF/A, tagged PDF,
+ // attachments, ...) are emitted once per document; chunked generation
+ // would lose them in the merge, so both force sequential mode.
+ if m.config.Protection != nil || m.config.HasDocumentCatalog() {
return m.generateSequentially(ctx)
}
diff --git a/go.mod b/go.mod
index 17dc60ec..90066571 100644
--- a/go.mod
+++ b/go.mod
@@ -2,14 +2,16 @@ module github.com/avdoseferovic/paper
go 1.26.4
+toolchain go1.26.5
+
require (
github.com/andybalholm/cascadia v1.3.3
github.com/boombuler/barcode v1.1.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/tdewolff/parse/v2 v2.8.13
- golang.org/x/image v0.39.0
+ golang.org/x/image v0.43.0
golang.org/x/net v0.55.0
)
-require golang.org/x/text v0.37.0 // indirect
+require golang.org/x/text v0.38.0
diff --git a/go.sum b/go.sum
index a66f1a94..fb2b028e 100644
--- a/go.sum
+++ b/go.sum
@@ -18,8 +18,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
-golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
+golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
+golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -72,8 +72,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/go.work.sum b/go.work.sum
index 71476089..46adf685 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -1,6 +1,9 @@
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
diff --git a/html.go b/html.go
index 2d8db489..a9ad2ae2 100644
--- a/html.go
+++ b/html.go
@@ -22,9 +22,16 @@ import (
// the HTML configures the page; an explicit config argument disables @page
// handling entirely (documented precedence rule, no field-level merging).
func FromHTML(ctx context.Context, htmlStr string, cfgs ...*entity.Config) (*core.Pdf, error) {
+ return FromHTMLWithOptions(ctx, htmlStr, nil, cfgs...)
+}
+
+// FromHTMLWithOptions is FromHTML with additional html conversion options
+// (e.g. html.WithImageBaseDir, html.WithStrictAssets) appended to the
+// config-derived defaults.
+func FromHTMLWithOptions(ctx context.Context, htmlStr string, opts []html.Option, cfgs ...*entity.Config) (*core.Pdf, error) {
if len(cfgs) > 0 {
m := New(cfgs...)
- err := m.AddHTML(ctx, htmlStr)
+ err := m.addHTMLWithOptions(ctx, htmlStr, opts)
if err != nil {
return nil, err
}
@@ -35,13 +42,13 @@ func FromHTML(ctx context.Context, htmlStr string, cfgs ...*entity.Config) (*cor
// shape the document. When @page is present, the HTML is re-translated
// against the resulting page geometry (content width affects layout).
m := New()
- doc, err := html.DocumentFromString(ctx, htmlStr, m.htmlOptions()...)
+ doc, err := html.DocumentFromString(ctx, htmlStr, append(m.htmlOptions(), opts...)...)
if err != nil {
return nil, err
}
if doc.Page != nil {
m = New(configFromPageOptions(doc.Page))
- err = m.AddHTML(ctx, htmlStr)
+ err = m.addHTMLWithOptions(ctx, htmlStr, opts)
if err != nil {
return nil, err
}
@@ -114,7 +121,11 @@ func FromHTMLReader(ctx context.Context, r io.Reader, cfgs ...*entity.Config) (*
// html.FromString directly and append the returned rows via m.AddRows(rows...).
// Supported HTML subset is documented in docs/html-support.md.
func (m *Paper) AddHTML(ctx context.Context, htmlStr string) error {
- doc, err := html.DocumentFromString(ctx, htmlStr, m.htmlOptions()...)
+ return m.addHTMLWithOptions(ctx, htmlStr, nil)
+}
+
+func (m *Paper) addHTMLWithOptions(ctx context.Context, htmlStr string, opts []html.Option) error {
+ doc, err := html.DocumentFromString(ctx, htmlStr, append(m.htmlOptions(), opts...)...)
if err != nil {
return err
}
diff --git a/internal/imagecodec/normalize.go b/internal/imagecodec/normalize.go
new file mode 100644
index 00000000..23c92f17
--- /dev/null
+++ b/internal/imagecodec/normalize.go
@@ -0,0 +1,89 @@
+package imagecodec
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ goimage "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ "image/png"
+ "strings"
+
+ svgraster "github.com/avdoseferovic/paper/internal/svg"
+ "github.com/avdoseferovic/paper/pkg/consts/extension"
+ _ "golang.org/x/image/tiff"
+ _ "golang.org/x/image/webp"
+)
+
+var errInvalidImageDimensions = errors.New("image decode produced invalid dimensions")
+
+type Normalized struct {
+ Bytes []byte
+ Extension extension.Type
+ Width int
+ Height int
+}
+
+func (n Normalized) HasDimensions() bool {
+ return n.Width > 0 && n.Height > 0
+}
+
+func NormalizeForPDF(data []byte, ext extension.Type) (Normalized, error) {
+ ext = canonicalExtension(ext)
+ switch ext {
+ case extension.Svg:
+ pngBytes, width, height, err := svgraster.Rasterize(data, 0, 0)
+ if err != nil {
+ return Normalized{}, fmt.Errorf("svg rasterize: %w", err)
+ }
+ return Normalized{
+ Bytes: pngBytes,
+ Extension: extension.Png,
+ Width: width,
+ Height: height,
+ }, nil
+ case extension.WebP, extension.Tif, extension.Tiff:
+ return rasterToPNG(data, ext)
+ case extension.Jpg, extension.Jpeg, extension.Png, extension.Gif:
+ return Normalized{
+ Bytes: data,
+ Extension: ext,
+ }, nil
+ default:
+ return Normalized{
+ Bytes: data,
+ Extension: ext,
+ }, nil
+ }
+}
+
+func canonicalExtension(ext extension.Type) extension.Type {
+ return extension.Type(strings.ToLower(string(ext)))
+}
+
+func rasterToPNG(data []byte, ext extension.Type) (Normalized, error) {
+ img, _, err := goimage.Decode(bytes.NewReader(data))
+ if err != nil {
+ return Normalized{}, fmt.Errorf("%s decode: %w", ext, err)
+ }
+ bounds := img.Bounds()
+ width := bounds.Dx()
+ height := bounds.Dy()
+ if width <= 0 || height <= 0 {
+ return Normalized{}, fmt.Errorf("%w: %s %dx%d", errInvalidImageDimensions, ext, width, height)
+ }
+
+ var buf bytes.Buffer
+ err = png.Encode(&buf, img)
+ if err != nil {
+ return Normalized{}, fmt.Errorf("%s png encode: %w", ext, err)
+ }
+
+ return Normalized{
+ Bytes: buf.Bytes(),
+ Extension: extension.Png,
+ Width: width,
+ Height: height,
+ }, nil
+}
diff --git a/internal/pdf/catalog.go b/internal/pdf/catalog.go
new file mode 100644
index 00000000..19083962
--- /dev/null
+++ b/internal/pdf/catalog.go
@@ -0,0 +1,152 @@
+package pdf
+
+import "time"
+
+type ViewerPreferences struct {
+ PageLayout string
+ PageMode string
+ HideToolbar bool
+ HideMenubar bool
+ HideWindowUI bool
+ FitWindow bool
+ CenterWindow bool
+ DisplayDocTitle bool
+ OpenPage int
+ OpenZoom string
+}
+
+type PageLabelRange struct {
+ PageIndex int
+ Style string
+ Prefix string
+ Start int
+}
+
+type FileAttachment struct {
+ FileName string
+ MIMEType string
+ Description string
+ AFRelationship string
+ Data []byte
+ CreationDate time.Time
+}
+
+type NamedDestination struct {
+ Name string
+ PageIndex int
+ FitType string
+ Top float64
+ Left float64
+ Zoom float64
+}
+
+type PageAnnotation struct {
+ PageIndex int
+ Subtype string
+ Rect [4]float64
+ URI string
+ DestName string
+ DestPage *int
+ Contents string
+ Name string
+ Open bool
+ Color *[3]float64
+ QuadPoints [][8]float64
+}
+
+type PageGeometry struct {
+ PageIndex int
+ Rotate int
+ CropBox *[4]float64
+ BleedBox *[4]float64
+ TrimBox *[4]float64
+ ArtBox *[4]float64
+}
+
+type attachmentFileSpecRef struct {
+ name string
+ ref int
+}
+
+type FormFieldType int
+
+const (
+ FormFieldText FormFieldType = iota
+ FormFieldCheckbox
+ FormFieldRadio
+ FormFieldDropdown
+ FormFieldListBox
+ FormFieldPushButton
+ FormFieldSignature
+)
+
+type FormFieldFlags uint32
+
+type FormField struct {
+ Name string
+ Type FormFieldType
+ Value string
+ Default string
+ Flags FormFieldFlags
+ Rect [4]float64
+ PageIndex int
+
+ FontSize float64
+ FontName string
+ TextColor [3]float64
+ BGColor *[3]float64
+ BorderColor *[3]float64
+ BorderWidth float64
+
+ Options []string
+ ExportValue string
+ Children []FormField
+}
+
+type PdfALevel int
+
+const (
+ PdfA2B PdfALevel = iota
+ PdfA2U
+ PdfA2A
+ PdfA3B
+ PdfA1B
+ PdfA1A
+ PdfA3A
+ PdfA4
+ PdfA4F
+ PdfA4E
+)
+
+type PdfAConfig struct {
+ Level PdfALevel
+ ICCProfile []byte
+ OutputCondition string
+ XMPSchemas []XMPSchema
+ XMPProperties []XMPPropertyBlock
+}
+
+type XMPSchema struct {
+ Schema string
+ NamespaceURI string
+ Prefix string
+ Properties []XMPSchemaProperty
+}
+
+type XMPSchemaProperty struct {
+ Name string
+ ValueType string
+ Category string
+ Description string
+}
+
+type XMPPropertyBlock struct {
+ Namespace string
+ Prefix string
+ Properties []XMPProperty
+}
+
+type XMPProperty struct {
+ Name string
+ Value string
+}
diff --git a/internal/pdf/catalog_write.go b/internal/pdf/catalog_write.go
new file mode 100644
index 00000000..1c747ae7
--- /dev/null
+++ b/internal/pdf/catalog_write.go
@@ -0,0 +1,466 @@
+package pdf
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// deterministicDocTime replaces time.Now() for zero-valued document dates when
+// deterministic output is requested, so repeated builds are byte-identical.
+var deterministicDocTime = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
+
+// SetViewerPreferences configures catalog viewer hints (page layout, page
+// mode, window flags, and the initial view).
+func (f *PDF) SetViewerPreferences(prefs ViewerPreferences) {
+ f.viewerPrefs = &prefs
+}
+
+// SetPageLabels defines the page label ranges shown by viewers instead of
+// physical page numbers. PageIndex is zero-based.
+func (f *PDF) SetPageLabels(labels ...PageLabelRange) {
+ f.pageLabels = append([]PageLabelRange(nil), labels...)
+}
+
+// SetAttachments embeds the given files in the document (/EmbeddedFiles name
+// tree plus /AF associated-files array).
+func (f *PDF) SetAttachments(attachments ...FileAttachment) {
+ clones := make([]FileAttachment, len(attachments))
+ for i, attachment := range attachments {
+ attachment.Data = append([]byte(nil), attachment.Data...)
+ clones[i] = attachment
+ }
+ f.attachments = clones
+}
+
+// SetNamedDestinations defines named destinations resolvable from links and
+// the /Dests name tree. Destinations with empty names or out-of-range page
+// indexes are skipped at output time.
+func (f *PDF) SetNamedDestinations(destinations ...NamedDestination) {
+ f.namedDests = append([]NamedDestination(nil), destinations...)
+}
+
+// SetPageAnnotations adds page-level annotations (links, text notes, and
+// markup annotations) to the generated pages. PageIndex is zero-based and
+// rectangles are in PDF points with a bottom-left origin.
+func (f *PDF) SetPageAnnotations(annotations ...PageAnnotation) {
+ clones := make([]PageAnnotation, len(annotations))
+ for i, annotation := range annotations {
+ if annotation.DestPage != nil {
+ destPage := *annotation.DestPage
+ annotation.DestPage = &destPage
+ }
+ if annotation.Color != nil {
+ color := *annotation.Color
+ annotation.Color = &color
+ }
+ annotation.QuadPoints = append([][8]float64(nil), annotation.QuadPoints...)
+ clones[i] = annotation
+ }
+ f.pageAnnotations = clones
+}
+
+// SetPageGeometries configures per-page geometry entries (/Rotate and the
+// Crop/Bleed/Trim/Art boxes). PageIndex is zero-based; box values are PDF
+// points. Rotations that are not multiples of 90 are skipped.
+func (f *PDF) SetPageGeometries(geometries ...PageGeometry) {
+ clones := make([]PageGeometry, len(geometries))
+ for i, geometry := range geometries {
+ geometry.CropBox = cloneBoxPtr(geometry.CropBox)
+ geometry.BleedBox = cloneBoxPtr(geometry.BleedBox)
+ geometry.TrimBox = cloneBoxPtr(geometry.TrimBox)
+ geometry.ArtBox = cloneBoxPtr(geometry.ArtBox)
+ clones[i] = geometry
+ }
+ f.pageGeometries = clones
+}
+
+func cloneBoxPtr(box *[4]float64) *[4]float64 {
+ if box == nil {
+ return nil
+ }
+ clone := *box
+ return &clone
+}
+
+// SetFileID sets an explicit trailer /ID. It takes precedence over the
+// derived deterministic ID and the encryption ID.
+func (f *PDF) SetFileID(id []byte) {
+ f.fileID = append([]byte(nil), id...)
+}
+
+// SetDeterministic makes repeated builds of the same document byte-identical:
+// zero-valued creation/modification dates become a fixed timestamp, resource
+// dictionaries are sorted, and a content-derived trailer /ID is emitted.
+func (f *PDF) SetDeterministic(deterministic bool) {
+ f.deterministic = deterministic
+ if deterministic {
+ f.catalogSort = true
+ }
+}
+
+// docTime resolves a document timestamp honoring deterministic mode.
+func (f *PDF) docTime(tm time.Time) time.Time {
+ if tm.IsZero() && f.deterministic {
+ return deterministicDocTime
+ }
+ return timeOrNow(tm)
+}
+
+// pageObjectNumber returns the object number of a zero-based page index.
+// Pages are written first, two objects per page (dict + content), starting at
+// object 3.
+func pageObjectNumber(pageIndex int) int {
+ return 3 + 2*pageIndex
+}
+
+// putPageGeometry writes the /Rotate and page-box entries for one page.
+// pageNum is 1-based.
+func (f *PDF) putPageGeometry(pageNum int) {
+ for _, geometry := range f.pageGeometries {
+ if geometry.PageIndex != pageNum-1 {
+ continue
+ }
+ rotate := ((geometry.Rotate % 360) + 360) % 360
+ if rotate != 0 && rotate%90 == 0 {
+ f.outf("/Rotate %d", rotate)
+ }
+ f.putGeometryBox("CropBox", geometry.CropBox)
+ f.putGeometryBox("BleedBox", geometry.BleedBox)
+ f.putGeometryBox("TrimBox", geometry.TrimBox)
+ f.putGeometryBox("ArtBox", geometry.ArtBox)
+ }
+}
+
+func (f *PDF) putGeometryBox(name string, box *[4]float64) {
+ if box == nil {
+ return
+ }
+ f.outf("/%s [%.2f %.2f %.2f %.2f]", name, box[0], box[1], box[2], box[3])
+}
+
+// customAnnotationsForPage returns the serialized custom annotations for a
+// 1-based page number.
+func (f *PDF) customAnnotationsForPage(pageNum int) []string {
+ var out []string
+ for _, annotation := range f.pageAnnotations {
+ if annotation.PageIndex != pageNum-1 {
+ continue
+ }
+ out = append(out, f.serializeAnnotation(annotation))
+ }
+ return out
+}
+
+func (f *PDF) serializeAnnotation(annotation PageAnnotation) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "<>", f.textstring(annotation.URI))
+ case annotation.DestName != "":
+ fmt.Fprintf(&b, " /Dest %s", f.textstring(annotation.DestName))
+ case annotation.DestPage != nil:
+ fmt.Fprintf(&b, " /Dest [%d 0 R /Fit]", pageObjectNumber(*annotation.DestPage))
+ }
+ case "Text":
+ if annotation.Contents != "" {
+ fmt.Fprintf(&b, " /Contents %s", f.textstring(annotation.Contents))
+ }
+ icon := annotation.Name
+ if icon == "" {
+ icon = "Note"
+ }
+ fmt.Fprintf(&b, " /Name /%s", pdfNameEscape(icon))
+ if annotation.Open {
+ b.WriteString(" /Open true")
+ }
+ default:
+ // Markup annotations (Highlight, Underline, Squiggly, StrikeOut, ...)
+ // require /QuadPoints; derive them from the rectangle when absent.
+ quads := annotation.QuadPoints
+ if len(quads) == 0 {
+ quads = [][8]float64{{
+ annotation.Rect[0], annotation.Rect[3],
+ annotation.Rect[2], annotation.Rect[3],
+ annotation.Rect[0], annotation.Rect[1],
+ annotation.Rect[2], annotation.Rect[1],
+ }}
+ }
+ b.WriteString(" /QuadPoints [")
+ for i, quad := range quads {
+ for j, v := range quad {
+ if i > 0 || j > 0 {
+ b.WriteByte(' ')
+ }
+ fmt.Fprintf(&b, "%.2f", v)
+ }
+ }
+ b.WriteString("]")
+ if annotation.Contents != "" {
+ fmt.Fprintf(&b, " /Contents %s", f.textstring(annotation.Contents))
+ }
+ }
+ if annotation.Color != nil {
+ fmt.Fprintf(&b, " /C [%.2f %.2f %.2f]", annotation.Color[0], annotation.Color[1], annotation.Color[2])
+ }
+ b.WriteString(">>")
+ return b.String()
+}
+
+// putAttachmentObjects writes the embedded-file stream and filespec objects
+// and records their references for the catalog name tree.
+func (f *PDF) putAttachmentObjects() {
+ f.attachmentRefs = f.attachmentRefs[:0]
+ for _, attachment := range f.attachments {
+ if strings.TrimSpace(attachment.FileName) == "" {
+ continue
+ }
+ mime := attachment.MIMEType
+ if mime == "" {
+ mime = "application/octet-stream"
+ }
+ relationship := attachment.AFRelationship
+ if relationship == "" {
+ relationship = "Unspecified"
+ }
+
+ f.newobj()
+ streamRef := f.n
+ stream := f.encryptedStream(attachment.Data)
+ if f.err != nil {
+ return
+ }
+ modDate := f.docTime(attachment.CreationDate)
+ f.outf("<< /Type /EmbeddedFile /Subtype /%s /Length %d /Params << /Size %d /ModDate %s >> >>",
+ pdfNameEscape(mime), len(stream), len(attachment.Data), f.textstring(pdfDateString(modDate)))
+ f.putstream(stream)
+ f.out("endobj")
+
+ f.newobj()
+ f.out("<< /Type /Filespec")
+ f.outf("/F %s", f.textstring(attachment.FileName))
+ f.outf("/UF %s", f.textstring(attachment.FileName))
+ if attachment.Description != "" {
+ f.outf("/Desc %s", f.textstring(attachment.Description))
+ }
+ f.outf("/AFRelationship /%s", pdfNameEscape(relationship))
+ f.outf("/EF << /F %d 0 R /UF %d 0 R >>", streamRef, streamRef)
+ f.out(">>")
+ f.out("endobj")
+
+ f.attachmentRefs = append(f.attachmentRefs, attachmentFileSpecRef{name: attachment.FileName, ref: f.n})
+ }
+}
+
+// namedDestinationValue serializes one destination array.
+func namedDestinationValue(dest NamedDestination) string {
+ pageRef := pageObjectNumber(dest.PageIndex)
+ switch dest.FitType {
+ case "XYZ":
+ zoom := "null"
+ if dest.Zoom != 0 {
+ zoom = fmt.Sprintf("%.2f", dest.Zoom)
+ }
+ return fmt.Sprintf("[%d 0 R /XYZ %.2f %.2f %s]", pageRef, dest.Left, dest.Top, zoom)
+ case "FitH":
+ return fmt.Sprintf("[%d 0 R /FitH %.2f]", pageRef, dest.Top)
+ case "FitV":
+ return fmt.Sprintf("[%d 0 R /FitV %.2f]", pageRef, dest.Left)
+ default:
+ return fmt.Sprintf("[%d 0 R /Fit]", pageRef)
+ }
+}
+
+// validNamedDestinations returns the emittable destinations sorted by name.
+func (f *PDF) validNamedDestinations() []NamedDestination {
+ valid := make([]NamedDestination, 0, len(f.namedDests))
+ for _, dest := range f.namedDests {
+ if dest.Name == "" || dest.PageIndex < 0 || dest.PageIndex >= f.page {
+ continue
+ }
+ valid = append(valid, dest)
+ }
+ sort.Slice(valid, func(i, j int) bool { return valid[i].Name < valid[j].Name })
+ return valid
+}
+
+// putCatalogNames emits the /Names dictionary (JavaScript, named
+// destinations, embedded files) plus the /AF associated-files array.
+func (f *PDF) putCatalogNames() {
+ destinations := f.validNamedDestinations()
+ hasNames := f.javascript != nil || len(destinations) > 0 || len(f.attachmentRefs) > 0
+ if !hasNames {
+ return
+ }
+ f.out("/Names <<")
+ if f.javascript != nil {
+ f.outf("/JavaScript %d 0 R", f.nJs)
+ }
+ if len(destinations) > 0 {
+ var b strings.Builder
+ b.WriteString("/Dests << /Names [")
+ for i, dest := range destinations {
+ if i > 0 {
+ b.WriteByte(' ')
+ }
+ fmt.Fprintf(&b, "%s %s", f.textstring(dest.Name), namedDestinationValue(dest))
+ }
+ b.WriteString("] >>")
+ f.out(b.String())
+ }
+ if len(f.attachmentRefs) > 0 {
+ refs := append([]attachmentFileSpecRef(nil), f.attachmentRefs...)
+ sort.Slice(refs, func(i, j int) bool { return refs[i].name < refs[j].name })
+ var b strings.Builder
+ b.WriteString("/EmbeddedFiles << /Names [")
+ for i, ref := range refs {
+ if i > 0 {
+ b.WriteByte(' ')
+ }
+ fmt.Fprintf(&b, "%s %d 0 R", f.textstring(ref.name), ref.ref)
+ }
+ b.WriteString("] >>")
+ f.out(b.String())
+ }
+ f.out(">>")
+ if len(f.attachmentRefs) > 0 {
+ var b strings.Builder
+ b.WriteString("/AF [")
+ for i, ref := range f.attachmentRefs {
+ if i > 0 {
+ b.WriteByte(' ')
+ }
+ fmt.Fprintf(&b, "%d 0 R", ref.ref)
+ }
+ b.WriteString("]")
+ f.out(b.String())
+ }
+}
+
+// putPageLabels emits the /PageLabels number tree.
+func (f *PDF) putPageLabels() {
+ if len(f.pageLabels) == 0 {
+ return
+ }
+ labels := append([]PageLabelRange(nil), f.pageLabels...)
+ sort.SliceStable(labels, func(i, j int) bool { return labels[i].PageIndex < labels[j].PageIndex })
+ var b strings.Builder
+ b.WriteString("/PageLabels << /Nums [")
+ for i, label := range labels {
+ if label.PageIndex < 0 {
+ continue
+ }
+ if i > 0 {
+ b.WriteByte(' ')
+ }
+ fmt.Fprintf(&b, "%d <<", label.PageIndex)
+ if label.Style != "" {
+ fmt.Fprintf(&b, " /S /%s", pdfNameEscape(label.Style))
+ }
+ if label.Prefix != "" {
+ fmt.Fprintf(&b, " /P %s", f.textstring(label.Prefix))
+ }
+ if label.Start > 0 {
+ fmt.Fprintf(&b, " /St %d", label.Start)
+ }
+ b.WriteString(" >>")
+ }
+ b.WriteString("] >>")
+ f.out(b.String())
+}
+
+// putViewerPreferences emits /PageLayout, /PageMode, the /ViewerPreferences
+// dictionary, and the initial-view /OpenAction.
+func (f *PDF) putViewerPreferences() {
+ prefs := f.viewerPrefs
+ if prefs == nil {
+ return
+ }
+ if prefs.PageLayout != "" {
+ f.outf("/PageLayout /%s", pdfNameEscape(prefs.PageLayout))
+ }
+ if prefs.PageMode != "" {
+ f.outf("/PageMode /%s", pdfNameEscape(prefs.PageMode))
+ }
+ flags := []struct {
+ name string
+ set bool
+ }{
+ {"HideToolbar", prefs.HideToolbar},
+ {"HideMenubar", prefs.HideMenubar},
+ {"HideWindowUI", prefs.HideWindowUI},
+ {"FitWindow", prefs.FitWindow},
+ {"CenterWindow", prefs.CenterWindow},
+ {"DisplayDocTitle", prefs.DisplayDocTitle},
+ }
+ hasFlag := false
+ for _, flag := range flags {
+ if flag.set {
+ hasFlag = true
+ break
+ }
+ }
+ if hasFlag {
+ f.out("/ViewerPreferences <<")
+ for _, flag := range flags {
+ if flag.set {
+ f.outf("/%s true", flag.name)
+ }
+ }
+ f.out(">>")
+ }
+ f.putOpenAction(prefs)
+}
+
+func (f *PDF) putOpenAction(prefs *ViewerPreferences) {
+ if prefs.OpenPage == 0 && prefs.OpenZoom == "" {
+ return
+ }
+ if prefs.OpenPage < 0 || prefs.OpenPage >= f.page {
+ return
+ }
+ pageRef := pageObjectNumber(prefs.OpenPage)
+ switch prefs.OpenZoom {
+ case "":
+ f.outf("/OpenAction [%d 0 R /Fit]", pageRef)
+ case "Fit":
+ f.outf("/OpenAction [%d 0 R /Fit]", pageRef)
+ case "FitH":
+ f.outf("/OpenAction [%d 0 R /FitH null]", pageRef)
+ case "FitV":
+ f.outf("/OpenAction [%d 0 R /FitV null]", pageRef)
+ case "FitB":
+ f.outf("/OpenAction [%d 0 R /FitB]", pageRef)
+ default:
+ percent := strings.TrimSuffix(strings.TrimSpace(prefs.OpenZoom), "%")
+ value, err := strconv.ParseFloat(percent, 64)
+ if err != nil || value <= 0 {
+ f.outf("/OpenAction [%d 0 R /Fit]", pageRef)
+ return
+ }
+ //nolint:dupword // XYZ destinations take two literal null operands.
+ f.outf("/OpenAction [%d 0 R /XYZ null null %.2f]", pageRef, value/100)
+ }
+}
+
+// trailerFileID returns the /ID value to write: the explicit file ID, a
+// content-derived deterministic ID, or nil.
+func (f *PDF) trailerFileID() []byte {
+ if len(f.fileID) > 0 {
+ return f.fileID
+ }
+ if f.deterministic {
+ sum := sha256.Sum256(f.buffer.Bytes())
+ return sum[:16]
+ }
+ return nil
+}
diff --git a/internal/pdf/catalog_write_test.go b/internal/pdf/catalog_write_test.go
new file mode 100644
index 00000000..54f489c3
--- /dev/null
+++ b/internal/pdf/catalog_write_test.go
@@ -0,0 +1,229 @@
+package pdf
+
+import (
+ "bytes"
+ "testing"
+)
+
+func newCatalogTestPDF(t *testing.T, pages int) *PDF {
+ t.Helper()
+ f := NewCustom(&InitType{OrientationStr: "P", UnitStr: "mm", SizeStr: "A4"})
+ f.SetCompression(false)
+ for range pages {
+ f.AddPage()
+ }
+ f.SetFont("Helvetica", "", 12)
+ f.Cell(40, 10, "catalog")
+ return f
+}
+
+func assertContainsAll(t *testing.T, out []byte, wants ...string) {
+ t.Helper()
+ for _, want := range wants {
+ if !bytes.Contains(out, []byte(want)) {
+ t.Fatalf("expected output to contain %q", want)
+ }
+ }
+}
+
+func TestSetViewerPreferencesEmitsCatalogEntries(t *testing.T) {
+ f := newCatalogTestPDF(t, 2)
+ f.SetViewerPreferences(ViewerPreferences{
+ PageLayout: "TwoPageRight",
+ PageMode: "UseThumbs",
+ HideToolbar: true,
+ DisplayDocTitle: true,
+ OpenPage: 1,
+ OpenZoom: "FitH",
+ })
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out,
+ "/PageLayout /TwoPageRight",
+ "/PageMode /UseThumbs",
+ "/HideToolbar true",
+ "/DisplayDocTitle true",
+ "/ViewerPreferences <<",
+ "/OpenAction [5 0 R /FitH null]",
+ )
+}
+
+func TestViewerPreferencesZeroOpenPageAloneEmitsNoOpenAction(t *testing.T) {
+ f := newCatalogTestPDF(t, 1)
+ f.SetViewerPreferences(ViewerPreferences{HideMenubar: true})
+ out := mustOutput(t, f)
+
+ if bytes.Contains(out, []byte("/OpenAction")) {
+ t.Fatal("unexpected /OpenAction for zero-value OpenPage")
+ }
+ assertContainsAll(t, out, "/HideMenubar true")
+}
+
+func TestViewerPreferencesPercentZoomOpenAction(t *testing.T) {
+ f := newCatalogTestPDF(t, 1)
+ f.SetViewerPreferences(ViewerPreferences{OpenZoom: "125%"})
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out, "/OpenAction [3 0 R /XYZ null null 1.25]")
+}
+
+func TestSetPageLabelsEmitsNumberTree(t *testing.T) {
+ f := newCatalogTestPDF(t, 2)
+ f.SetPageLabels(
+ PageLabelRange{PageIndex: 1, Style: "D", Start: 1},
+ PageLabelRange{PageIndex: 0, Style: "r", Prefix: "front-"},
+ )
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out,
+ "/PageLabels << /Nums [",
+ "0 << /S /r /P (front-) >>",
+ "1 << /S /D /St 1 >>",
+ )
+}
+
+func TestSetAttachmentsEmitsEmbeddedFiles(t *testing.T) {
+ f := newCatalogTestPDF(t, 1)
+ f.SetAttachments(FileAttachment{
+ FileName: "invoice.xml",
+ MIMEType: "application/xml",
+ Description: "Invoice XML",
+ AFRelationship: "Data",
+ Data: []byte(""),
+ })
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out,
+ "/Type /EmbeddedFile",
+ "/Subtype /application#2Fxml",
+ "",
+ "/Type /Filespec",
+ "/F (invoice.xml)",
+ "/Desc (Invoice XML)",
+ "/AFRelationship /Data",
+ "/EF <<",
+ "/EmbeddedFiles << /Names [(invoice.xml)",
+ "/AF [",
+ )
+}
+
+func TestSetAttachmentsDefaultsMIMEAndRelationship(t *testing.T) {
+ f := newCatalogTestPDF(t, 1)
+ f.SetAttachments(FileAttachment{FileName: "raw.bin", Data: []byte{1, 2, 3}})
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out,
+ "/Subtype /application#2Foctet-stream",
+ "/AFRelationship /Unspecified",
+ )
+}
+
+func TestSetNamedDestinationsEmitsSortedDestsNameTree(t *testing.T) {
+ f := newCatalogTestPDF(t, 3)
+ f.SetNamedDestinations(
+ NamedDestination{Name: "section", PageIndex: 2, FitType: "FitH", Top: 700},
+ NamedDestination{Name: "top", PageIndex: 0},
+ NamedDestination{Name: "detail", PageIndex: 1, FitType: "XYZ", Left: 10, Top: 20, Zoom: 1.5},
+ NamedDestination{Name: "", PageIndex: 0},
+ NamedDestination{Name: "invalid", PageIndex: 9},
+ )
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out,
+ "/Dests << /Names [(detail) [5 0 R /XYZ 10.00 20.00 1.50] (section) [7 0 R /FitH 700.00] (top) [3 0 R /Fit]] >>",
+ )
+ if bytes.Contains(out, []byte("(invalid)")) {
+ t.Fatal("destination with out-of-range page index should be skipped")
+ }
+}
+
+func TestSetPageAnnotationsEmitsAnnotationDicts(t *testing.T) {
+ destPage := 1
+ f := newCatalogTestPDF(t, 2)
+ f.SetPageAnnotations(
+ PageAnnotation{PageIndex: 0, Subtype: "Link", Rect: [4]float64{40, 50, 120, 70}, URI: "https://example.com"},
+ PageAnnotation{PageIndex: 0, Subtype: "Link", Rect: [4]float64{40, 80, 120, 100}, DestName: "section"},
+ PageAnnotation{PageIndex: 0, Subtype: "Link", Rect: [4]float64{40, 110, 120, 130}, DestPage: &destPage},
+ PageAnnotation{PageIndex: 1, Subtype: "Text", Rect: [4]float64{10, 20, 30, 40}, Contents: "note body", Name: "Comment", Open: true},
+ PageAnnotation{PageIndex: 1, Subtype: "Highlight", Rect: [4]float64{90, 100, 140, 120}, Color: &[3]float64{1, 1, 0}},
+ )
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out,
+ "/Subtype /Link /Rect [40.00 50.00 120.00 70.00] /Border [0 0 0] /A <>",
+ "/Dest (section)",
+ "/Dest [5 0 R /Fit]",
+ "/Subtype /Text /Rect [10.00 20.00 30.00 40.00] /Contents (note body) /Name /Comment /Open true",
+ "/Subtype /Highlight /Rect [90.00 100.00 140.00 120.00] /QuadPoints [90.00 120.00 140.00 120.00 90.00 100.00 140.00 100.00] /C [1.00 1.00 0.00]",
+ )
+}
+
+func TestTextAnnotationIconDefaultsToNote(t *testing.T) {
+ f := newCatalogTestPDF(t, 1)
+ f.SetPageAnnotations(PageAnnotation{PageIndex: 0, Subtype: "Text", Rect: [4]float64{1, 2, 3, 4}, Contents: "x"})
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out, "/Name /Note")
+}
+
+func TestSetPageGeometriesEmitsPageEntries(t *testing.T) {
+ crop := [4]float64{10, 20, 210, 290}
+ trim := [4]float64{15, 25, 205, 285}
+ f := newCatalogTestPDF(t, 2)
+ f.SetPageGeometries(
+ PageGeometry{PageIndex: 0, Rotate: 90, CropBox: &crop},
+ PageGeometry{PageIndex: 1, Rotate: -90, TrimBox: &trim},
+ )
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out,
+ "/Rotate 90",
+ "/Rotate 270",
+ "/CropBox [10.00 20.00 210.00 290.00]",
+ "/TrimBox [15.00 25.00 205.00 285.00]",
+ )
+}
+
+func TestSetPageGeometriesSkipsInvalidRotation(t *testing.T) {
+ f := newCatalogTestPDF(t, 1)
+ f.SetPageGeometries(PageGeometry{PageIndex: 0, Rotate: 45})
+ out := mustOutput(t, f)
+
+ if bytes.Contains(out, []byte("/Rotate")) {
+ t.Fatal("rotation not multiple of 90 should be skipped")
+ }
+}
+
+func TestSetFileIDWritesTrailerID(t *testing.T) {
+ f := newCatalogTestPDF(t, 1)
+ f.SetFileID([]byte{0xab, 0xcd, 0xef})
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out, "/ID []")
+}
+
+func TestDeterministicOutputIsStable(t *testing.T) {
+ build := func() []byte {
+ f := newCatalogTestPDF(t, 1)
+ f.SetDeterministic(true)
+ return mustOutput(t, f)
+ }
+ first := build()
+ second := build()
+
+ if !bytes.Equal(first, second) {
+ t.Fatal("deterministic mode should produce identical output")
+ }
+ if !bytes.Contains(first, []byte("/ID [<")) {
+ t.Fatal("deterministic mode should derive a trailer /ID")
+ }
+}
+
+func TestExplicitFileIDBeatsDeterministicID(t *testing.T) {
+ f := newCatalogTestPDF(t, 1)
+ f.SetDeterministic(true)
+ f.SetFileID([]byte{0x01})
+ out := mustOutput(t, f)
+
+ assertContainsAll(t, out, "/ID [<01><01>]")
+}
diff --git a/internal/pdf/def.go b/internal/pdf/def.go
index b0b2ecb5..448fa069 100644
--- a/internal/pdf/def.go
+++ b/internal/pdf/def.go
@@ -3,7 +3,6 @@ package pdf
import (
"bytes"
"crypto/sha256"
- "encoding/gob"
"encoding/json"
"fmt"
"io"
@@ -13,6 +12,7 @@ import (
// Version of FPDF from which this package is derived
const (
cnPDFVersion = "1.7"
+ pdfVersion14 = "1.4"
)
type blendModeType struct {
@@ -124,75 +124,6 @@ func (p PointType) XY() (float64, float64) {
return p.X, p.Y
}
-// ImageInfoType contains size, color and other information about an image.
-// Changes to this structure should be reflected in its GobEncode and GobDecode
-// methods.
-type ImageInfoType struct {
- data []byte // Raw image data
- smask []byte // Soft Mask, an 8bit per-pixel transparency mask
- n int // Image object number
- w float64 // Width
- h float64 // Height
- cs string // Color space
- pal []byte // Image color palette
- bpc int // Bits Per Component
- f string // Image filter
- dp string // DecodeParms
- trns []int // Transparency mask
- scale float64 // Document scale factor
- dpi float64 // Dots-per-inch found from image file (png only)
- i string // SHA-1 checksum of the above values.
-}
-
-func generateImageID(info *ImageInfoType) (string, error) {
- b, err := info.GobEncode()
- if err != nil {
- return "", err
- }
- return fmt.Sprintf("%x", sha256.Sum256(b)), nil
-}
-
-// GobEncode encodes the receiving image to a byte slice.
-func (info *ImageInfoType) GobEncode() ([]byte, error) {
- fields := []any{
- info.data, info.smask, info.n, info.w, info.h, info.cs,
- info.pal, info.bpc, info.f, info.dp, info.trns, info.scale, info.dpi,
- }
- w := new(bytes.Buffer)
- encoder := gob.NewEncoder(w)
- for j, field := range fields {
- err := encoder.Encode(field)
- if err != nil {
- return nil, fmt.Errorf("encode image field %d: %w", j, err)
- }
- }
- return w.Bytes(), nil
-}
-
-// GobDecode decodes the specified byte buffer (generated by GobEncode) into
-// the receiving image.
-func (info *ImageInfoType) GobDecode(buf []byte) error {
- fields := []any{
- &info.data, &info.smask, &info.n, &info.w, &info.h,
- &info.cs, &info.pal, &info.bpc, &info.f, &info.dp, &info.trns, &info.scale, &info.dpi,
- }
- r := bytes.NewBuffer(buf)
- decoder := gob.NewDecoder(r)
- for j, field := range fields {
- err := decoder.Decode(field)
- if err != nil {
- return fmt.Errorf("decode image field %d: %w", j, err)
- }
- }
-
- var err error
- info.i, err = generateImageID(info)
- if err != nil {
- return fmt.Errorf("generate image id: %w", err)
- }
- return nil
-}
-
// PointConvert returns the value of pt, expressed in points (1/72 inch), as a
// value expressed in the unit of measure specified in New(). Since font
// management in PDF uses points, this method can help with line height
@@ -214,30 +145,6 @@ func (f *PDF) UnitToPointConvert(u float64) float64 {
return u * f.k
}
-// Extent returns the width and height of the image in the units of the PDF
-// object.
-func (info *ImageInfoType) Extent() (float64, float64) {
- return info.Width(), info.Height()
-}
-
-// Width returns the width of the image in the units of the PDF object.
-func (info *ImageInfoType) Width() float64 {
- return info.w / (info.scale * info.dpi / 72)
-}
-
-// Height returns the height of the image in the units of the PDF object.
-func (info *ImageInfoType) Height() float64 {
- return info.h / (info.scale * info.dpi / 72)
-}
-
-// SetDpi sets the dots per inch for an image. PNG images MAY have their dpi
-// set automatically, if the image specifies it. DPI information is not
-// currently available automatically for JPG and GIF images, so if it's
-// important to you, you can set it here. It defaults to 72 dpi.
-func (info *ImageInfoType) SetDpi(dpi float64) {
- info.dpi = dpi
-}
-
type fontFileType struct {
length1, length2 int64
n int
@@ -344,6 +251,23 @@ type PDF struct {
links []intLinkType // array of internal links
outlines []outlineType // array of outlines
outlineRoot int // root of outlines
+ acroFormFields []FormField // interactive AcroForm fields to emit
+ acroForm *preparedAcroForm // AcroForm objects prepared for output
+ viewerPrefs *ViewerPreferences // catalog viewer preference hints
+ pageLabels []PageLabelRange // catalog /PageLabels ranges
+ attachments []FileAttachment // embedded files to emit
+ attachmentRefs []attachmentFileSpecRef // filespec objects written for attachments
+ namedDests []NamedDestination // catalog /Names /Dests entries
+ pageAnnotations []PageAnnotation // configured page annotations
+ pageGeometries []PageGeometry // per-page rotation and geometry boxes
+ fileID []byte // explicit trailer /ID bytes
+ deterministic bool // derive stable /ID and zero implicit dates
+ pdfA *PdfAConfig // PDF/A conformance configuration
+ taggedPDF bool // emit a tagged (accessible) PDF
+ structTreeRoot int // object number of the structure tree root
+ outputIntentObj int // object number of the PDF/A output intent
+ xmpObj int // object number of the XMP metadata stream
+ language string // document language (/Lang)
autoPageBreak bool // automatic page breaking
acceptPageBreak func() bool // returns true to accept page break
pageBreakTrigger float64 // threshold used to trigger page breaks
diff --git a/internal/pdf/def_image.go b/internal/pdf/def_image.go
new file mode 100644
index 00000000..31399d81
--- /dev/null
+++ b/internal/pdf/def_image.go
@@ -0,0 +1,101 @@
+package pdf
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/gob"
+ "fmt"
+)
+
+// ImageInfoType contains size, color and other information about an image.
+// Changes to this structure should be reflected in its GobEncode and GobDecode
+// methods.
+type ImageInfoType struct {
+ data []byte // Raw image data
+ smask []byte // Soft Mask, an 8bit per-pixel transparency mask
+ n int // Image object number
+ w float64 // Width
+ h float64 // Height
+ cs string // Color space
+ pal []byte // Image color palette
+ bpc int // Bits Per Component
+ f string // Image filter
+ dp string // DecodeParms
+ trns []int // Transparency mask
+ scale float64 // Document scale factor
+ dpi float64 // Dots-per-inch found from image file (png only)
+ i string // SHA-1 checksum of the above values.
+}
+
+func generateImageID(info *ImageInfoType) (string, error) {
+ b, err := info.GobEncode()
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%x", sha256.Sum256(b)), nil
+}
+
+// GobEncode encodes the receiving image to a byte slice.
+func (info *ImageInfoType) GobEncode() ([]byte, error) {
+ fields := []any{
+ info.data, info.smask, info.n, info.w, info.h, info.cs,
+ info.pal, info.bpc, info.f, info.dp, info.trns, info.scale, info.dpi,
+ }
+ w := new(bytes.Buffer)
+ encoder := gob.NewEncoder(w)
+ for j, field := range fields {
+ err := encoder.Encode(field)
+ if err != nil {
+ return nil, fmt.Errorf("encode image field %d: %w", j, err)
+ }
+ }
+ return w.Bytes(), nil
+}
+
+// GobDecode decodes the specified byte buffer (generated by GobEncode) into
+// the receiving image.
+func (info *ImageInfoType) GobDecode(buf []byte) error {
+ fields := []any{
+ &info.data, &info.smask, &info.n, &info.w, &info.h,
+ &info.cs, &info.pal, &info.bpc, &info.f, &info.dp, &info.trns, &info.scale, &info.dpi,
+ }
+ r := bytes.NewBuffer(buf)
+ decoder := gob.NewDecoder(r)
+ for j, field := range fields {
+ err := decoder.Decode(field)
+ if err != nil {
+ return fmt.Errorf("decode image field %d: %w", j, err)
+ }
+ }
+
+ var err error
+ info.i, err = generateImageID(info)
+ if err != nil {
+ return fmt.Errorf("generate image id: %w", err)
+ }
+ return nil
+}
+
+// Extent returns the width and height of the image in the units of the PDF
+// object.
+func (info *ImageInfoType) Extent() (float64, float64) {
+ return info.Width(), info.Height()
+}
+
+// Width returns the width of the image in the units of the PDF object.
+func (info *ImageInfoType) Width() float64 {
+ return info.w / (info.scale * info.dpi / 72)
+}
+
+// Height returns the height of the image in the units of the PDF object.
+func (info *ImageInfoType) Height() float64 {
+ return info.h / (info.scale * info.dpi / 72)
+}
+
+// SetDpi sets the dots per inch for an image. PNG images MAY have their dpi
+// set automatically, if the image specifies it. DPI information is not
+// currently available automatically for JPG and GIF images, so if it's
+// important to you, you can set it here. It defaults to 72 dpi.
+func (info *ImageInfoType) SetDpi(dpi float64) {
+ info.dpi = dpi
+}
diff --git a/internal/pdf/document.go b/internal/pdf/document.go
index 04947b8a..90ef19b4 100644
--- a/internal/pdf/document.go
+++ b/internal/pdf/document.go
@@ -113,6 +113,12 @@ func (f *PDF) SetXmpMetadata(xmpStream []byte) {
f.xmp = xmpStream
}
+// SetLanguage sets the document language that is written to the catalog's
+// /Lang entry, e.g. "en-US".
+func (f *PDF) SetLanguage(lang string) {
+ f.language = lang
+}
+
// AliasNbPages defines an alias for the total number of pages. It will be
// substituted as the document is closed. An empty string is replaced with the
// string "{nb}".
@@ -172,18 +178,27 @@ func (f *PDF) RegisterAlias(alias, replacement string) {
func (f *PDF) replaceAliases() {
for mode := range 2 {
for alias, replacement := range f.aliasMap {
+ searchStr, replaceStr := alias, replacement
if mode == 1 {
- alias = utf8toutf16(alias, false)
- replacement = utf8toutf16(replacement, false)
+ searchStr = utf8toutf16(alias, false)
+ replaceStr = utf8toutf16(replacement, false)
}
+ replaced := false
for n := 1; n <= f.page; n++ {
s := f.pages[n].String()
- if strings.Contains(s, alias) {
- s = strings.ReplaceAll(s, alias, replacement)
+ if strings.Contains(s, searchStr) {
+ s = strings.ReplaceAll(s, searchStr, replaceStr)
f.pages[n].Truncate(0)
f.pages[n].WriteString(s)
+ replaced = true
}
}
+ if mode == 1 && replaced {
+ // The UTF-16BE replacement was injected as identity CIDs;
+ // claim those CIDs in every UTF-8 font so the subsets map
+ // them to real glyphs instead of .notdef.
+ f.claimUTF8AliasRunes(replacement)
+ }
}
}
}
@@ -234,10 +249,26 @@ func (f *PDF) putinfo() {
if len(f.creator) > 0 {
f.outf("/Creator %s", f.textstring(f.creator))
}
- creation := timeOrNow(f.creationDate)
- f.outf("/CreationDate %s", f.textstring("D:"+creation.Format("20060102150405")))
- mod := timeOrNow(f.modDate)
- f.outf("/ModDate %s", f.textstring("D:"+mod.Format("20060102150405")))
+ creation := f.docTime(f.creationDate)
+ f.outf("/CreationDate %s", f.textstring(pdfDateString(creation)))
+ mod := f.docTime(f.modDate)
+ f.outf("/ModDate %s", f.textstring(pdfDateString(mod)))
+}
+
+// pdfDateString formats a time as a PDF date string including the timezone
+// offset, e.g. "D:20200102150405+05'30'" or "D:20200102150405Z" for UTC.
+func pdfDateString(tm time.Time) string {
+ base := tm.Format("D:20060102150405")
+ _, offset := tm.Zone()
+ if offset == 0 {
+ return base + "Z"
+ }
+ sign := byte('+')
+ if offset < 0 {
+ sign = '-'
+ offset = -offset
+ }
+ return fmt.Sprintf("%s%c%02d'%02d'", base, sign, offset/3600, offset%3600/60)
}
func (f *PDF) putcatalog() {
@@ -271,13 +302,30 @@ func (f *PDF) putcatalog() {
if len(f.outlines) > 0 {
f.outf("/Outlines %d 0 R", f.outlineRoot)
- f.out("/PageMode /UseOutlines")
+ if f.viewerPrefs == nil || f.viewerPrefs.PageMode == "" {
+ f.out("/PageMode /UseOutlines")
+ }
}
- if f.javascript != nil {
- f.out("/Names <<")
- f.outf("/JavaScript %d 0 R", f.nJs)
- f.out(">>")
+ f.putCatalogNames()
+ f.putPageLabels()
+ f.putViewerPreferences()
+
+ if f.xmpObj > 0 {
+ f.outf("/Metadata %d 0 R", f.xmpObj)
+ }
+ if f.outputIntentObj > 0 {
+ f.outf("/OutputIntents [%d 0 R]", f.outputIntentObj)
+ }
+ if f.acroForm != nil {
+ f.outf("/AcroForm %d 0 R", f.acroForm.ref)
+ }
+ if f.taggedPDF && f.structTreeRoot > 0 {
+ f.out("/MarkInfo << /Marked true >>")
+ f.outf("/StructTreeRoot %d 0 R", f.structTreeRoot)
+ }
+ if f.language != "" {
+ f.outf("/Lang %s", f.textstring(f.language))
}
}
@@ -509,8 +557,16 @@ func (f *PDF) puttrailer() {
f.outf("/Size %d", f.n+1)
f.outf("/Root %d 0 R", f.n)
f.outf("/Info %d 0 R", f.n-1)
+ explicitID := f.trailerFileID()
+ if len(explicitID) > 0 {
+ id := pdfHexString(explicitID)
+ f.outf("/ID [%s%s]", id, id)
+ }
if f.protect.encrypted {
f.outf("/Encrypt %d 0 R", f.protect.objNum)
+ if len(explicitID) > 0 {
+ return
+ }
if f.protect.algorithm == ProtectionAES128 && len(f.protect.fileID) > 0 {
id := pdfHexString(f.protect.fileID)
f.outf("/ID [%s%s]", id, id)
@@ -525,10 +581,12 @@ func pdfHexString(data []byte) string {
}
func (f *PDF) putxmp() {
+ f.xmpObj = 0
if len(f.xmp) == 0 {
return
}
f.newobj()
+ f.xmpObj = f.n
stream := f.encryptedStream(f.xmp)
if f.err != nil {
return
@@ -544,15 +602,34 @@ func (f *PDF) enddoc() {
}
f.putheader()
+ // AcroForm object numbers are reserved relative to the page objects (two
+ // objects per page starting at object 3), so the form must be prepared
+ // before the pages are written (page dicts reference the widget refs in
+ // their /Annots arrays) and emitted immediately after them.
+ f.prepareAcroForm(f.page)
f.putpages()
+ f.putAcroFormObjects()
+ f.putTaggedStructure(f.page)
+ if f.err != nil {
+ return
+ }
f.putresources()
if f.err != nil {
return
}
f.putbookmarks()
+ f.putAttachmentObjects()
+ if f.err != nil {
+ return
+ }
+ f.preparePdfAMetadata()
f.putxmp()
+ f.putPdfAOutputIntent()
+ if f.err != nil {
+ return
+ }
f.newobj()
f.out("<<")
diff --git a/internal/pdf/document_test.go b/internal/pdf/document_test.go
index 4f97748b..65cb2a9d 100644
--- a/internal/pdf/document_test.go
+++ b/internal/pdf/document_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
+ "regexp"
"testing"
"time"
)
@@ -77,6 +78,58 @@ func TestXmpMetadataEmitsStream(t *testing.T) {
}
}
+func TestInfoDatesIncludeTimezoneOffset(t *testing.T) {
+ f := readyPDF(t)
+ f.SetCreationDate(time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC))
+ f.SetModificationDate(time.Date(2021, 2, 3, 4, 5, 6, 0, time.FixedZone("IST", 5*3600+30*60)))
+ out := mustOutput(t, f)
+ if !bytes.Contains(out, []byte("/CreationDate (D:20200102030405Z)")) {
+ t.Errorf("expected UTC creation date with Z suffix, got %s",
+ regexp.MustCompile(`/CreationDate [^\n]*`).Find(out))
+ }
+ if !bytes.Contains(out, []byte("/ModDate (D:20210203040506+05'30')")) {
+ t.Errorf("expected mod date with +05'30' offset, got %s",
+ regexp.MustCompile(`/ModDate [^\n]*`).Find(out))
+ }
+}
+
+func TestInfoDatesNegativeOffset(t *testing.T) {
+ f := readyPDF(t)
+ f.SetCreationDate(time.Date(2020, 1, 2, 3, 4, 5, 0, time.FixedZone("EST", -5*3600)))
+ out := mustOutput(t, f)
+ if !bytes.Contains(out, []byte("/CreationDate (D:20200102030405-05'00')")) {
+ t.Errorf("expected creation date with -05'00' offset, got %s",
+ regexp.MustCompile(`/CreationDate [^\n]*`).Find(out))
+ }
+}
+
+func TestXmpMetadataReferencedFromCatalog(t *testing.T) {
+ f := readyPDF(t)
+ f.SetXmpMetadata([]byte(``))
+ out := mustOutput(t, f)
+
+ m := regexp.MustCompile(`(\d+) 0 obj\n<< /Type /Metadata`).FindSubmatch(out)
+ if m == nil {
+ t.Fatal("missing metadata stream object")
+ }
+ ref := []byte("/Metadata " + string(m[1]) + " 0 R")
+ if !bytes.Contains(out, ref) {
+ t.Fatalf("catalog does not reference metadata object: want %q", ref)
+ }
+ if n := len(regexp.MustCompile(`/Metadata \d+ 0 R`).FindAll(out, -1)); n != 1 {
+ t.Fatalf("expected exactly one catalog /Metadata reference, found %d", n)
+ }
+}
+
+func TestSetLanguageEmitsLangInCatalog(t *testing.T) {
+ f := readyPDF(t)
+ f.SetLanguage("en-US")
+ out := mustOutput(t, f)
+ if !bytes.Contains(out, []byte("/Lang (en-US)")) {
+ t.Fatal("expected /Lang entry in catalog")
+ }
+}
+
func TestAliasNbPagesReplacedInOutput(t *testing.T) {
f := NewCustom(&InitType{UnitStr: "mm"})
f.AliasNbPages("")
diff --git a/internal/pdf/emoji.go b/internal/pdf/emoji.go
index 30a9c111..f2cdbea5 100644
--- a/internal/pdf/emoji.go
+++ b/internal/pdf/emoji.go
@@ -61,7 +61,11 @@ func (f *PDF) getOrAssignCID(r int) int {
cid := r
if r > 0xFFFF {
cid = f.findNextFreeCID()
- } else if original, used := f.currentFont.usedRunes[r]; used && original != r {
+ } else if original, used := f.currentFont.usedRunes[r]; used && original != r && original != 0 {
+ // The identity slot is claimed by a different rune: remap to the PUA.
+ // A slot whose value is 0 was merely pre-seeded by makeSubsetRange
+ // (reserved for identity use, e.g. alias replacement digits) and is
+ // claimed by the matching rune instead of being remapped.
cid = f.findNextFreeCID()
}
@@ -70,6 +74,37 @@ func (f *PDF) getOrAssignCID(r int) int {
return cid
}
+// claimUTF8AliasRunes marks the runes of an alias replacement string as used
+// identity CIDs in every UTF-8 font. Alias replacement injects identity
+// UTF-16BE CIDs directly into page content streams, bypassing
+// getOrAssignCID, so the font subsets must be told to keep those glyphs.
+func (f *PDF) claimUTF8AliasRunes(replacement string) {
+ for _, font := range f.fonts {
+ if font.utf8File == nil || font.usedRunes == nil {
+ continue
+ }
+ for _, r := range replacement {
+ claimIdentityCID(font, int(pdfGlyphRune(r)))
+ }
+ }
+}
+
+// claimIdentityCID claims the identity CID slot for r unless another rune
+// already owns it. A missing slot or the pre-seeded sentinel value 0 counts
+// as unclaimed.
+func claimIdentityCID(font fontDefType, r int) {
+ if r <= 0 || r > 0xFFFF {
+ return
+ }
+ if owner, used := font.usedRunes[r]; used && owner != 0 && owner != r {
+ return
+ }
+ font.usedRunes[r] = r
+ if font.runeToCID != nil {
+ font.runeToCID[r] = r
+ }
+}
+
func (f *PDF) findNextFreeCID() int {
for cid := 0xE000; cid <= 0xF8FF; cid++ {
if _, used := f.currentFont.usedRunes[cid]; !used {
diff --git a/internal/pdf/emoji_test.go b/internal/pdf/emoji_test.go
index 0d79acfd..15374c53 100644
--- a/internal/pdf/emoji_test.go
+++ b/internal/pdf/emoji_test.go
@@ -45,6 +45,43 @@ func TestStringToCIDsMapsNonBreakingSpaceToSpaceGlyph(t *testing.T) {
}
}
+func TestGetOrAssignCIDClaimsPreseededIdentitySlots(t *testing.T) {
+ t.Parallel()
+
+ // makeSubsetRange pre-seeds usedRunes[cid] = 0 for low cids so that alias
+ // replacement can inject identity-encoded text. A typed rune whose own
+ // slot is merely reserved must claim it instead of moving to the PUA.
+ f := &PDF{
+ currentFont: fontDefType{
+ usedRunes: makeSubsetRange(57),
+ runeToCID: map[int]int{},
+ },
+ }
+
+ cid := f.getOrAssignCID('0') // 0x30 = 48, inside the pre-seeded range
+ if cid != '0' {
+ t.Fatalf("expected digit to keep identity CID 0x30, got 0x%04X", cid)
+ }
+ if gotRune := f.currentFont.usedRunes['0']; gotRune != '0' {
+ t.Fatalf("expected slot 0x30 to be claimed by rune '0', got U+%04X", gotRune)
+ }
+}
+
+func TestGetOrAssignCIDStillRemapsConflictingRunes(t *testing.T) {
+ t.Parallel()
+
+ f := &PDF{
+ currentFont: fontDefType{
+ usedRunes: map[int]int{0x30: 0x1F600}, // slot claimed by another rune
+ runeToCID: map[int]int{},
+ },
+ }
+
+ if cid := f.getOrAssignCID(0x30); cid != 0xE000 {
+ t.Fatalf("expected conflicting rune to be remapped to the PUA, got 0x%04X", cid)
+ }
+}
+
func TestColorEmojiToggleRequiresColorFont(t *testing.T) {
t.Parallel()
diff --git a/internal/pdf/errors.go b/internal/pdf/errors.go
index 47b958e1..9e6bb78c 100644
--- a/internal/pdf/errors.go
+++ b/internal/pdf/errors.go
@@ -24,6 +24,7 @@ const (
)
var (
+ errAcroFormObjectNumber = errors.New("acroform object number mismatch")
errAlphaOutOfRange = errors.New("alpha value (0.0 - 1.0) is out of range")
errClipEndSequence = errors.New("error attempting to end clip operation out of sequence")
errClipProcedureOpen = errors.New("clip procedure must be explicitly ended")
@@ -40,6 +41,7 @@ var (
errTransformationProcedureOpen = errors.New("transformation procedure must be explicitly ended")
errTrueTypeCollectionEmpty = errors.New("TrueType collection has no fonts")
errTrueTypeCollectionOffset = errors.New("TrueType collection font offset is invalid")
+ errType1FontSegmentBounds = errors.New("type1 font segment lengths exceed file size")
errUnsupportedCFFFont = errors.New("unsupported OpenType CFF font")
errUnsupportedFontType = errors.New("unsupported font type")
errUnsupportedImageType = errors.New("unsupported image type")
diff --git a/internal/pdf/font_pdf_test.go b/internal/pdf/font_pdf_test.go
index c5ecfed7..67dc37ca 100644
--- a/internal/pdf/font_pdf_test.go
+++ b/internal/pdf/font_pdf_test.go
@@ -165,6 +165,56 @@ func TestOutputReturnsErrorWhenUTF8FontSubsettingFails(t *testing.T) {
}
}
+func TestAliasNbPagesUTF8FontRendersDigits(t *testing.T) {
+ fontBytes, err := os.ReadFile(filepath.Join("..", "..", "docs", "assets", "fonts", "arial-unicode-ms.ttf"))
+ if err != nil {
+ t.Fatalf("read custom font fixture: %v", err)
+ }
+
+ pdf := NewCustom(&InitType{
+ OrientationStr: "P",
+ UnitStr: "mm",
+ SizeStr: "A4",
+ })
+ pdf.SetCompression(false)
+ pdf.AliasNbPages("")
+ pdf.AddUTF8FontFromBytes("arial-unicode-ms", "", fontBytes)
+ pdf.AddPage()
+ pdf.SetFont("arial-unicode-ms", "", 12)
+ pdf.Write(5, "Page 1 of {nb}")
+ pdf.AddPage()
+ pdf.Write(5, "Page 2 of {nb}")
+
+ var out bytes.Buffer
+ if err := pdf.Output(&out); err != nil {
+ t.Fatalf("output alias PDF: %v", err)
+ }
+ body := out.Bytes()
+
+ // The alias is replaced with identity UTF-16BE CIDs ("2" -> 0x0032), so
+ // the page content must contain the digit CID.
+ if !bytes.Contains(body, []byte{0x00, '2'}) {
+ t.Fatal("expected page content to contain the identity CID for digit 2")
+ }
+ // The digit CID must be a real glyph: present in ToUnicode...
+ if !bytes.Contains(body, []byte("<0032> <0032>")) {
+ t.Fatal("expected ToUnicode CMap to map CID 0x32 to U+0032")
+ }
+ // ...and mapped to a non-.notdef glyph in the subset font.
+ var utf8Font fontDefType
+ for _, font := range pdf.fonts {
+ if font.utf8File != nil {
+ utf8Font = font
+ }
+ }
+ if utf8Font.utf8File == nil {
+ t.Fatal("missing UTF-8 font definition")
+ }
+ if glyph := utf8Font.utf8File.codeSymbolDictionary[0x32]; glyph == 0 {
+ t.Fatalf("expected CID 0x32 to map to a real glyph, got glyph %d", glyph)
+ }
+}
+
func TestUTF8FontSubsettingOutputIsDeterministic(t *testing.T) {
fontBytes, err := os.ReadFile(filepath.Join("..", "..", "docs", "assets", "fonts", "arial-unicode-ms.ttf"))
if err != nil {
diff --git a/internal/pdf/fonts.go b/internal/pdf/fonts.go
index 4f0d97d3..e1deb58d 100644
--- a/internal/pdf/fonts.go
+++ b/internal/pdf/fonts.go
@@ -203,9 +203,6 @@ func (f *PDF) newUTF8FontDefinition(fontKey, fileStr string, utf8File *utf8FontF
runeToCID: make(map[int]int),
hasColorGlyphs: utf8File.hasColorGlyphs,
}
- for cid, r := range sbarr {
- def.runeToCID[r] = cid
- }
return def
}
@@ -723,9 +720,11 @@ func (f *PDF) putFontFileObject(file string, info fontFileType) {
}
compressed := strings.HasSuffix(file, ".z")
if !compressed && info.length2 > 0 {
- buf := font[6:info.length1]
- buf = append(buf, font[6+info.length1+6:info.length2]...)
- font = buf
+ font, err = stripType1Segments(font, info.length1, info.length2)
+ if err != nil {
+ f.err = err
+ return
+ }
}
stream := f.encryptedStream(font)
if f.err != nil {
@@ -744,6 +743,25 @@ func (f *PDF) putFontFileObject(file string, info fontFileType) {
f.out("endobj")
}
+// stripType1Segments extracts the clear-text and binary portions of a Type1
+// PFB font file. The file layout is a 6-byte segment header, length1 bytes of
+// clear text, another 6-byte segment header, and length2 bytes of binary data.
+func stripType1Segments(font []byte, length1, length2 int64) ([]byte, error) {
+ const headerSize = 6
+ clearStart := int64(headerSize)
+ clearEnd := clearStart + length1
+ binaryStart := clearEnd + headerSize
+ binaryEnd := binaryStart + length2
+ if length1 < 0 || length2 < 0 || binaryEnd > int64(len(font)) {
+ return nil, staticErrorf(errType1FontSegmentBounds,
+ "length1 %d, length2 %d, file size %d", length1, length2, len(font))
+ }
+ buf := make([]byte, 0, length1+length2)
+ buf = append(buf, font[clearStart:clearEnd]...)
+ buf = append(buf, font[binaryStart:binaryEnd]...)
+ return buf, nil
+}
+
func (f *PDF) fontFileContent(file string, info fontFileType) ([]byte, error) {
if info.embedded {
return info.content, nil
diff --git a/internal/pdf/fonts_extra_test.go b/internal/pdf/fonts_extra_test.go
index 834aae3a..850e1312 100644
--- a/internal/pdf/fonts_extra_test.go
+++ b/internal/pdf/fonts_extra_test.go
@@ -103,7 +103,9 @@ func TestAddFontFromBytesEmbeddedTrueType(t *testing.T) {
func TestAddFontFromReaderType1LoadsFontFileFromDisk(t *testing.T) {
dir := t.TempDir()
- fontFile := make([]byte, 40)
+ // PFB layout: 6-byte header + length1 (10) bytes + 6-byte header +
+ // length2 (30) bytes = 52 bytes minimum.
+ fontFile := make([]byte, 52)
for i := range fontFile {
fontFile[i] = byte(i)
}
@@ -303,6 +305,51 @@ func TestAddFontFromReaderDeduplicatesAndRegistersDiff(t *testing.T) {
}
}
+func TestPutFontFileObjectType1SegmentBounds(t *testing.T) {
+ // A Type1 PFB layout: 6-byte segment header, length1 bytes of clear text,
+ // another 6-byte header, length2 bytes of binary data, then a trailer.
+ const length1, length2 = 10, 4
+ font := make([]byte, 0, 6+length1+6+length2+5)
+ font = append(font, []byte("HDR1HD")...)
+ font = append(font, bytes.Repeat([]byte{'A'}, length1)...)
+ font = append(font, []byte("HDR2HD")...)
+ font = append(font, bytes.Repeat([]byte{'B'}, length2)...)
+ font = append(font, []byte("TRAIL")...)
+
+ f := NewCustom(&InitType{OrientationStr: "P", UnitStr: "mm", SizeStr: "A4"})
+ f.fontFiles["synth.pfb"] = fontFileType{}
+ f.putFontFileObject("synth.pfb", fontFileType{
+ embedded: true,
+ content: font,
+ length1: length1,
+ length2: length2,
+ })
+ if f.Err() {
+ t.Fatalf("putFontFileObject errored: %v", f.Error())
+ }
+ want := append(bytes.Repeat([]byte{'A'}, length1), bytes.Repeat([]byte{'B'}, length2)...)
+ if !bytes.Contains(f.buffer.Bytes(), want) {
+ t.Fatal("expected embedded stream to contain both Type1 segments")
+ }
+ if bytes.Contains(f.buffer.Bytes(), []byte("HDR2")) {
+ t.Fatal("expected PFB segment headers to be stripped from the stream")
+ }
+}
+
+func TestPutFontFileObjectType1MalformedLengthsSetError(t *testing.T) {
+ f := NewCustom(&InitType{OrientationStr: "P", UnitStr: "mm", SizeStr: "A4"})
+ f.fontFiles["bad.pfb"] = fontFileType{}
+ f.putFontFileObject("bad.pfb", fontFileType{
+ embedded: true,
+ content: make([]byte, 20),
+ length1: 10,
+ length2: 5, // 6+10+6+5 = 27 > 20: previously paniced via font[22:5]
+ })
+ if !f.Err() {
+ t.Fatal("expected error for Type1 lengths exceeding the font file size")
+ }
+}
+
func TestOutputFailsWhenFontFileMissingFromDisk(t *testing.T) {
f := NewCustom(&InitType{OrientationStr: "P", UnitStr: "mm", SizeStr: "A4"})
f.SetFontLocation(t.TempDir())
diff --git a/internal/pdf/form.go b/internal/pdf/form.go
new file mode 100644
index 00000000..d7fa2e64
--- /dev/null
+++ b/internal/pdf/form.go
@@ -0,0 +1,461 @@
+package pdf
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+type preparedAcroForm struct {
+ ref int
+ fields []preparedFormField
+ pageWidgets map[int][]int
+}
+
+type preparedFormField struct {
+ field FormField
+ ref int
+ checkbox preparedCheckboxAppearance
+ widgets []preparedFormWidget
+}
+
+type preparedFormWidget struct {
+ field FormField
+ ref int
+ appearance preparedRadioAppearance
+}
+
+type preparedCheckboxAppearance struct {
+ yesRef int
+ offRef int
+}
+
+type preparedRadioAppearance struct {
+ exportValue string
+ onRef int
+ offRef int
+}
+
+// SetAcroFormFields sets generated interactive PDF AcroForm fields.
+func (f *PDF) SetAcroFormFields(fields ...FormField) {
+ f.acroFormFields = clonePDFFormFields(fields)
+ f.acroForm = nil
+}
+
+func clonePDFFormFields(fields []FormField) []FormField {
+ if fields == nil {
+ return nil
+ }
+ clones := make([]FormField, len(fields))
+ for i, field := range fields {
+ clones[i] = clonePDFFormField(field)
+ }
+ return clones
+}
+
+func clonePDFFormField(field FormField) FormField {
+ field.BGColor = clonePDFColorTriple(field.BGColor)
+ field.BorderColor = clonePDFColorTriple(field.BorderColor)
+ field.Options = append([]string(nil), field.Options...)
+ field.Children = clonePDFFormFields(field.Children)
+ return field
+}
+
+func clonePDFColorTriple(color *[3]float64) *[3]float64 {
+ if color == nil {
+ return nil
+ }
+ clone := *color
+ return &clone
+}
+
+func (f *PDF) prepareAcroForm(pageCount int) {
+ f.acroForm = nil
+ if len(f.acroFormFields) == 0 {
+ return
+ }
+
+ nextRef := f.n + pageCount*2 + 1
+ prepared := &preparedAcroForm{
+ pageWidgets: make(map[int][]int),
+ }
+ for _, field := range f.acroFormFields {
+ if strings.TrimSpace(field.Name) == "" {
+ continue
+ }
+ preparedField := preparedFormField{
+ field: field,
+ ref: nextRef,
+ }
+ nextRef++
+ if field.Type == FormFieldRadio && len(field.Children) > 0 {
+ for _, child := range field.Children {
+ widget := preparedFormWidget{
+ field: child,
+ ref: nextRef,
+ appearance: preparedRadioAppearance{
+ exportValue: formExportValue(child),
+ onRef: nextRef + 1,
+ offRef: nextRef + 2,
+ },
+ }
+ nextRef += 3
+ preparedField.widgets = append(preparedField.widgets, widget)
+ if validFormPage(child.PageIndex, pageCount) {
+ prepared.pageWidgets[child.PageIndex] = append(prepared.pageWidgets[child.PageIndex], widget.ref)
+ }
+ }
+ } else {
+ if validFormPage(field.PageIndex, pageCount) {
+ prepared.pageWidgets[field.PageIndex] = append(prepared.pageWidgets[field.PageIndex], preparedField.ref)
+ }
+ if field.Type == FormFieldCheckbox {
+ preparedField.checkbox = preparedCheckboxAppearance{
+ yesRef: nextRef,
+ offRef: nextRef + 1,
+ }
+ nextRef += 2
+ }
+ }
+ prepared.fields = append(prepared.fields, preparedField)
+ }
+ if len(prepared.fields) == 0 {
+ return
+ }
+ prepared.ref = nextRef
+ f.acroForm = prepared
+}
+
+func validFormPage(pageIndex, pageCount int) bool {
+ return pageIndex >= 0 && pageIndex < pageCount
+}
+
+func (f *PDF) putAcroFormObjects() {
+ if f.acroForm == nil {
+ return
+ }
+ for _, field := range f.acroForm.fields {
+ f.putPreparedFormField(field)
+ if f.err != nil {
+ return
+ }
+ }
+ f.putAcroFormDictionary()
+}
+
+func (f *PDF) putPreparedFormField(field preparedFormField) {
+ f.newobjExpected(field.ref)
+ if f.err != nil {
+ return
+ }
+ f.putFormFieldDictionary(field)
+ f.out("endobj")
+
+ if field.field.Type == FormFieldCheckbox {
+ f.putCheckboxAppearanceStreams(field.field, field.checkbox)
+ return
+ }
+ for _, widget := range field.widgets {
+ f.putRadioWidgetObject(field.ref, widget)
+ if f.err != nil {
+ return
+ }
+ f.putRadioAppearanceStreams(widget.field, widget.appearance)
+ if f.err != nil {
+ return
+ }
+ }
+}
+
+func (f *PDF) newobjExpected(ref int) {
+ f.newobj()
+ if f.n != ref {
+ f.err = staticErrorf(errAcroFormObjectNumber, "got %d, want %d", f.n, ref)
+ }
+}
+
+func (f *PDF) putFormFieldDictionary(field preparedFormField) {
+ ff := field.field
+ f.out("<<")
+ f.outf("/T %s", f.textstring(ff.Name))
+ f.outf("/FT /%s", formFieldPDFType(ff.Type))
+ f.putFormCommonEntries(ff)
+ if ff.Type == FormFieldRadio && len(field.widgets) > 0 {
+ var kids fmtBuffer
+ kids.printf("/Kids [")
+ for _, widget := range field.widgets {
+ kids.printf("%d 0 R ", widget.ref)
+ }
+ kids.printf("]")
+ f.out(kids.String())
+ f.out(">>")
+ return
+ }
+ if validFormPage(ff.PageIndex, f.page) {
+ f.putMergedWidgetEntries(ff)
+ }
+ if ff.Type == FormFieldCheckbox {
+ f.outf("/AP << /N << /%s %d 0 R /Off %d 0 R >> >>",
+ pdfNameEscape(formExportValue(ff)), field.checkbox.yesRef, field.checkbox.offRef)
+ f.outf("/AS /%s", pdfNameEscape(checkboxAppearanceState(ff)))
+ }
+ f.out(">>")
+}
+
+func (f *PDF) putFormCommonEntries(field FormField) {
+ if field.Flags != 0 {
+ f.outf("/Ff %d", uint32(field.Flags))
+ }
+ if field.Value != "" {
+ f.outf("/V %s", f.formValueObject(field))
+ }
+ if field.Default != "" {
+ f.outf("/DV %s", f.formTextString(field.Default))
+ }
+ if len(field.Options) > 0 {
+ var opt fmtBuffer
+ opt.printf("/Opt [")
+ for _, option := range field.Options {
+ opt.printf("%s ", f.formTextString(option))
+ }
+ opt.printf("]")
+ f.out(opt.String())
+ }
+}
+
+func (f *PDF) formValueObject(field FormField) string {
+ switch field.Type {
+ case FormFieldCheckbox, FormFieldRadio:
+ return "/" + pdfNameEscape(field.Value)
+ case FormFieldText, FormFieldDropdown, FormFieldListBox, FormFieldPushButton, FormFieldSignature:
+ return f.formTextString(field.Value)
+ default:
+ return f.formTextString(field.Value)
+ }
+}
+
+func (f *PDF) formTextString(value string) string {
+ return f.textstring(value)
+}
+
+func (f *PDF) putMergedWidgetEntries(field FormField) {
+ f.out("/Type /Annot")
+ f.out("/Subtype /Widget")
+ f.outf("/Rect [%.2f %.2f %.2f %.2f]", field.Rect[0], field.Rect[1], field.Rect[2], field.Rect[3])
+ f.outf("/P %d 0 R", formPageObjectNumber(field.PageIndex))
+ if field.FontName != "" || field.FontSize != 0 || field.TextColor != [3]float64{} {
+ f.outf("/DA %s", f.textstring(formDefaultAppearance(field)))
+ }
+ f.putWidgetAppearanceCharacteristics(field)
+ f.putWidgetBorder(field)
+}
+
+func (f *PDF) putWidgetAppearanceCharacteristics(field FormField) {
+ if field.BorderColor == nil && field.BGColor == nil {
+ return
+ }
+ f.out("/MK <<")
+ if field.BorderColor != nil {
+ f.outf("/BC [%.3f %.3f %.3f]", field.BorderColor[0], field.BorderColor[1], field.BorderColor[2])
+ }
+ if field.BGColor != nil {
+ f.outf("/BG [%.3f %.3f %.3f]", field.BGColor[0], field.BGColor[1], field.BGColor[2])
+ }
+ f.out(">>")
+}
+
+func (f *PDF) putWidgetBorder(field FormField) {
+ if field.BorderWidth <= 0 {
+ return
+ }
+ f.outf("/BS << /W %.2f /S /S >>", field.BorderWidth)
+}
+
+func (f *PDF) putRadioWidgetObject(parentRef int, widget preparedFormWidget) {
+ f.newobjExpected(widget.ref)
+ if f.err != nil {
+ return
+ }
+ ff := widget.field
+ f.out("<<")
+ f.out("/Type /Annot")
+ f.out("/Subtype /Widget")
+ f.outf("/Rect [%.2f %.2f %.2f %.2f]", ff.Rect[0], ff.Rect[1], ff.Rect[2], ff.Rect[3])
+ f.outf("/Parent %d 0 R", parentRef)
+ if validFormPage(ff.PageIndex, f.page) {
+ f.outf("/P %d 0 R", formPageObjectNumber(ff.PageIndex))
+ }
+ f.putWidgetBorder(ff)
+ exportValue := formExportValue(ff)
+ f.outf("/AP << /N << /%s %d 0 R /Off %d 0 R >> >>",
+ pdfNameEscape(exportValue), widget.appearance.onRef, widget.appearance.offRef)
+ if ff.Value == exportValue {
+ f.outf("/AS /%s", pdfNameEscape(exportValue))
+ } else {
+ f.out("/AS /Off")
+ }
+ f.out(">>")
+ f.out("endobj")
+}
+
+func (f *PDF) putAcroFormDictionary() {
+ f.newobjExpected(f.acroForm.ref)
+ if f.err != nil {
+ return
+ }
+ var fields fmtBuffer
+ fields.printf("/Fields [")
+ for _, field := range f.acroForm.fields {
+ fields.printf("%d 0 R ", field.ref)
+ }
+ fields.printf("]")
+ f.out("<<")
+ f.out(fields.String())
+ f.out("/DR << /Font <<")
+ f.out("/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
+ f.out("/ZaDb << /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>")
+ f.out(">> >>")
+ f.out("/DA (/Helv 0 Tf 0 0 0 rg)")
+ f.out("/NeedAppearances true")
+ f.out(">>")
+ f.out("endobj")
+}
+
+func (f *PDF) putCheckboxAppearanceStreams(field FormField, appearance preparedCheckboxAppearance) {
+ width, height := formRectSize(field.Rect)
+ f.putFormXObjectStream(appearance.yesRef, width, height, checkboxOnAppearanceContent(width, height), true)
+ if f.err != nil {
+ return
+ }
+ f.putFormXObjectStream(appearance.offRef, width, height, checkboxOffAppearanceContent(width, height), false)
+}
+
+func (f *PDF) putRadioAppearanceStreams(field FormField, appearance preparedRadioAppearance) {
+ width, height := formRectSize(field.Rect)
+ f.putFormXObjectStream(appearance.onRef, width, height, radioOnAppearanceContent(width, height), false)
+ if f.err != nil {
+ return
+ }
+ f.putFormXObjectStream(appearance.offRef, width, height, radioOffAppearanceContent(width, height), false)
+}
+
+func (f *PDF) putFormXObjectStream(ref int, width, height float64, content string, includeZapf bool) {
+ f.newobjExpected(ref)
+ if f.err != nil {
+ return
+ }
+ stream := f.encryptedStream([]byte(content))
+ if f.err != nil {
+ return
+ }
+ f.out("<<")
+ f.out("/Type /XObject")
+ f.out("/Subtype /Form")
+ f.outf("/BBox [0 0 %.2f %.2f]", width, height)
+ if includeZapf {
+ f.out("/Resources << /Font << /ZaDb << /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >> >> >>")
+ }
+ f.outf("/Length %d", len(stream))
+ f.out(">>")
+ f.putstream(stream)
+ f.out("endobj")
+}
+
+func formFieldPDFType(fieldType FormFieldType) string {
+ switch fieldType {
+ case FormFieldText:
+ return "Tx"
+ case FormFieldCheckbox, FormFieldRadio, FormFieldPushButton:
+ return "Btn"
+ case FormFieldDropdown, FormFieldListBox:
+ return "Ch"
+ case FormFieldSignature:
+ return "Sig"
+ default:
+ return "Tx"
+ }
+}
+
+func formDefaultAppearance(field FormField) string {
+ fontName := strings.TrimSpace(field.FontName)
+ if fontName == "" {
+ fontName = "Helv"
+ }
+ return fmt.Sprintf("/%s %s Tf %s %s %s rg",
+ pdfNameEscape(fontName),
+ formatFormNumber(field.FontSize),
+ formatFormNumber(field.TextColor[0]),
+ formatFormNumber(field.TextColor[1]),
+ formatFormNumber(field.TextColor[2]))
+}
+
+func formatFormNumber(v float64) string {
+ if v == float64(int(v)) {
+ return strconv.Itoa(int(v))
+ }
+ return fmt.Sprintf("%.2f", v)
+}
+
+func formExportValue(field FormField) string {
+ if strings.TrimSpace(field.ExportValue) != "" {
+ return field.ExportValue
+ }
+ return "Yes"
+}
+
+func checkboxAppearanceState(field FormField) string {
+ if field.Value == formExportValue(field) {
+ return formExportValue(field)
+ }
+ return "Off"
+}
+
+func formRectSize(rect [4]float64) (float64, float64) {
+ width := rect[2] - rect[0]
+ height := rect[3] - rect[1]
+ if width <= 0 {
+ width = 1
+ }
+ if height <= 0 {
+ height = 1
+ }
+ return width, height
+}
+
+func formPageObjectNumber(pageIndex int) int {
+ return 3 + pageIndex*2
+}
+
+func checkboxOnAppearanceContent(width, height float64) string {
+ return fmt.Sprintf("q\n0.6 0.6 0.6 RG\n0.5 w\n0 0 %s %s re S\n"+
+ "0 0 0 rg\nBT\n/ZaDb %s Tf\n%s %s Td\n(4) Tj\nET\nQ",
+ formatFormNumber(width), formatFormNumber(height),
+ formatFormNumber(height*0.8), formatFormNumber(width*0.15), formatFormNumber(height*0.15))
+}
+
+func checkboxOffAppearanceContent(width, height float64) string {
+ return fmt.Sprintf("q\n0.6 0.6 0.6 RG\n0.5 w\n0 0 %s %s re S\nQ",
+ formatFormNumber(width), formatFormNumber(height))
+}
+
+func radioOnAppearanceContent(width, height float64) string {
+ cx := width / 2
+ cy := height / 2
+ radius := min(width, height) / 2 * 0.8
+ dot := radius * 0.5
+ return fmt.Sprintf("q\n0.6 0.6 0.6 RG\n0.5 w\n%s %s %s %s re S\n"+
+ "0 0 0 rg\n%s %s %s %s re f\nQ",
+ formatFormNumber(cx-radius), formatFormNumber(cy-radius),
+ formatFormNumber(radius*2), formatFormNumber(radius*2),
+ formatFormNumber(cx-dot), formatFormNumber(cy-dot),
+ formatFormNumber(dot*2), formatFormNumber(dot*2))
+}
+
+func radioOffAppearanceContent(width, height float64) string {
+ cx := width / 2
+ cy := height / 2
+ radius := min(width, height) / 2 * 0.8
+ return fmt.Sprintf("q\n0.6 0.6 0.6 RG\n0.5 w\n%s %s %s %s re S\nQ",
+ formatFormNumber(cx-radius), formatFormNumber(cy-radius),
+ formatFormNumber(radius*2), formatFormNumber(radius*2))
+}
diff --git a/internal/pdf/form_test.go b/internal/pdf/form_test.go
new file mode 100644
index 00000000..3b55aaf9
--- /dev/null
+++ b/internal/pdf/form_test.go
@@ -0,0 +1,80 @@
+package pdf
+
+import (
+ "bytes"
+ "regexp"
+ "testing"
+)
+
+func TestAcroFormTextFieldEmitted(t *testing.T) {
+ f := readyPDF(t)
+ f.SetAcroFormFields(FormField{
+ Name: "name",
+ Type: FormFieldText,
+ Value: "Ada",
+ Rect: [4]float64{50, 700, 250, 720},
+ PageIndex: 0,
+ })
+ out := mustOutput(t, f)
+
+ acroForm := regexp.MustCompile(`/AcroForm (\d+) 0 R`).FindSubmatch(out)
+ if acroForm == nil {
+ t.Fatal("catalog missing /AcroForm reference")
+ }
+ for _, want := range []string{
+ "/T (name)",
+ "/FT /Tx",
+ "/V (Ada)",
+ "/Subtype /Widget",
+ "/NeedAppearances true",
+ "/Annots [",
+ } {
+ if !bytes.Contains(out, []byte(want)) {
+ t.Fatalf("expected AcroForm output to contain %q", want)
+ }
+ }
+}
+
+func TestAcroFormCheckboxAndRadioObjectNumbering(t *testing.T) {
+ f := readyPDF(t)
+ f.AddPage()
+ f.SetAcroFormFields(
+ FormField{
+ Name: "agree",
+ Type: FormFieldCheckbox,
+ Value: "Yes",
+ Rect: [4]float64{50, 650, 65, 665},
+ PageIndex: 0,
+ },
+ FormField{
+ Name: "choice",
+ Type: FormFieldRadio,
+ Children: []FormField{
+ {Name: "a", Type: FormFieldRadio, ExportValue: "A", Rect: [4]float64{50, 600, 65, 615}, PageIndex: 0},
+ {Name: "b", Type: FormFieldRadio, ExportValue: "B", Rect: [4]float64{80, 600, 95, 615}, PageIndex: 1},
+ },
+ },
+ )
+ // newobjExpected inside the form putters errors out if the reserved
+ // object numbers do not line up with the actual emission order.
+ out := mustOutput(t, f)
+ for _, want := range []string{
+ "/FT /Btn",
+ "/AP << /N << /Yes",
+ "/AP << /N << /A",
+ "/AP << /N << /B",
+ "/AcroForm",
+ } {
+ if !bytes.Contains(out, []byte(want)) {
+ t.Fatalf("expected AcroForm output to contain %q", want)
+ }
+ }
+}
+
+func TestNoAcroFormWithoutFields(t *testing.T) {
+ f := readyPDF(t)
+ out := mustOutput(t, f)
+ if bytes.Contains(out, []byte("/AcroForm")) {
+ t.Fatal("unexpected /AcroForm entry without form fields")
+ }
+}
diff --git a/internal/pdf/graphics.go b/internal/pdf/graphics.go
index ffe669d9..a0558eca 100644
--- a/internal/pdf/graphics.go
+++ b/internal/pdf/graphics.go
@@ -120,14 +120,13 @@ func (f *PDF) SetAlpha(alpha float64, blendModeStr string) {
if f.err != nil {
return
}
- var bl blendModeType
switch blendModeStr {
case blendModeNormal, "Multiply", "Screen", "Overlay",
"Darken", "Lighten", "ColorDodge", "ColorBurn", "HardLight", "SoftLight",
"Difference", "Exclusion", "Hue", "Saturation", "Color", "Luminosity":
- bl.modeStr = blendModeStr
+ // valid blend mode name, used as-is
case "":
- bl.modeStr = blendModeNormal
+ blendModeStr = blendModeNormal
default:
f.err = fmt.Errorf("%w: %q", errUnrecognizedBlendMode, blendModeStr)
return
@@ -633,6 +632,11 @@ func (f *PDF) ClipRect(x, y, w, h float64, outline bool) {
// restore unclipped operations.
func (f *PDF) ClipText(x, y float64, txtStr string, outline bool) {
f.clipNest++
+ if f.isCurrentUTF8 {
+ // UTF-8 fonts use Identity-H encoding: the string must be converted
+ // to CIDs just like Text() does, not written as raw UTF-8 bytes.
+ txtStr = f.stringToCIDs(txtStr)
+ }
f.outf("q BT %.5f %.5f Td %d Tr (%s) Tj ET", x*f.k, (f.h-y)*f.k, intIf(outline, 5, 7), f.escape(txtStr))
}
diff --git a/internal/pdf/graphics_test.go b/internal/pdf/graphics_test.go
index 7cb79fad..fbd83ea3 100644
--- a/internal/pdf/graphics_test.go
+++ b/internal/pdf/graphics_test.go
@@ -1,6 +1,7 @@
package pdf
import (
+ "bytes"
"regexp"
"strings"
"testing"
@@ -34,6 +35,40 @@ func TestAlphaSetAndGet(t *testing.T) {
}
}
+func TestSetAlphaEmptyBlendModeNormalizesToNormal(t *testing.T) {
+ f := readyPDF(t)
+ f.SetAlpha(0.5, "")
+ if f.Err() {
+ t.Fatalf("SetAlpha errored: %v", f.Error())
+ }
+ if _, mode := f.GetAlpha(); mode != "Normal" {
+ t.Fatalf("GetAlpha mode = %q, want Normal", mode)
+ }
+ out := mustOutput(t, f)
+ if !bytes.Contains(out, []byte("/BM /Normal>>")) {
+ t.Fatal("expected ExtGState to contain /BM /Normal")
+ }
+ if bytes.Contains(out, []byte("/BM />>")) {
+ t.Fatal("ExtGState contains empty /BM name")
+ }
+}
+
+func TestClipTextUTF8FontWritesCIDs(t *testing.T) {
+ f := readyPDF(t)
+ f.SetCompression(false)
+ f.isCurrentUTF8 = true
+ f.currentFont.usedRunes = map[int]int{}
+ f.currentFont.runeToCID = map[int]int{}
+
+ f.ClipText(10, 20, "AB", false)
+ f.ClipEnd()
+
+ content := f.pages[f.page].Bytes()
+ if !bytes.Contains(content, []byte("(\x00A\x00B) Tj")) {
+ t.Fatalf("expected UTF-8 clip text to be CID encoded, content: %q", content)
+ }
+}
+
func TestSetAlphaOutOfRangeErrors(t *testing.T) {
f := readyPDF(t)
f.SetAlpha(1.5, "Normal")
diff --git a/internal/pdf/page.go b/internal/pdf/page.go
index 0b04a7ed..21ab3724 100644
--- a/internal/pdf/page.go
+++ b/internal/pdf/page.go
@@ -474,9 +474,13 @@ func (f *PDF) putpages() {
for t, pb := range f.pageBoxes[n] {
f.outf("/%s [%.2f %.2f %.2f %.2f]", t, pb.X, pb.Y, pb.Wd, pb.Ht)
}
+ f.putPageGeometry(n)
f.out("/Resources 2 0 R")
f.putPageAnnotations(n, hPt)
+ if f.taggedPDF {
+ f.outf("/StructParents %d", n-1)
+ }
if f.pdfVersion > "1.3" {
f.out("/Group <>")
}
@@ -484,8 +488,9 @@ func (f *PDF) putpages() {
f.out("endobj")
f.newobj()
+ pageContent := f.taggedPageContent(n, f.pages[n].Bytes())
if f.compress {
- data := sliceCompress(f.pages[n].Bytes())
+ data := sliceCompress(pageContent)
stream := f.encryptedStream(data)
if f.err != nil {
return
@@ -493,7 +498,7 @@ func (f *PDF) putpages() {
f.outf("<>", len(stream))
f.putstream(stream)
} else {
- stream := f.encryptedStream(f.pages[n].Bytes())
+ stream := f.encryptedStream(pageContent)
if f.err != nil {
return
}
@@ -520,7 +525,12 @@ func (f *PDF) putpages() {
}
func (f *PDF) putPageAnnotations(pageNum int, defaultHeight float64) {
- if len(f.pageLinks[pageNum]) == 0 {
+ var widgetRefs []int
+ if f.acroForm != nil {
+ widgetRefs = f.acroForm.pageWidgets[pageNum-1]
+ }
+ custom := f.customAnnotationsForPage(pageNum)
+ if len(f.pageLinks[pageNum]) == 0 && len(widgetRefs) == 0 && len(custom) == 0 {
return
}
var annots fmtBuffer
@@ -528,10 +538,29 @@ func (f *PDF) putPageAnnotations(pageNum int, defaultHeight float64) {
for _, pl := range f.pageLinks[pageNum] {
f.putPageLinkAnnotation(&annots, pl, defaultHeight)
}
+ for _, annotation := range custom {
+ annots.printf("%s ", annotation)
+ }
+ for _, ref := range widgetRefs {
+ annots.printf("%d 0 R ", ref)
+ }
annots.printf("]")
f.out(annots.String())
}
+// pageHeightPt returns the height in points of the given 1-based page number.
+// Only pages that differ from the default page size are recorded in
+// f.pageSizes; all other pages use the default page size.
+func (f *PDF) pageHeightPt(pageNum int) float64 {
+ if sz, ok := f.pageSizes[pageNum]; ok {
+ return sz.Ht
+ }
+ if f.defOrientation == "P" {
+ return f.defPageSize.Ht * f.k
+ }
+ return f.defPageSize.Wd * f.k
+}
+
func (f *PDF) putPageLinkAnnotation(annots *fmtBuffer, pl linkType, defaultHeight float64) {
annots.printf("<` + "\n")
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + "\n")
+ if f.title != "" {
+ b.WriteString(`` + xmlEscape(f.title) + `` + "\n")
+ }
+ if f.author != "" {
+ b.WriteString(`` + xmlEscape(f.author) + `` + "\n")
+ }
+ if f.language != "" {
+ b.WriteString(`` + xmlEscape(f.language) + `` + "\n")
+ }
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + xmlEscape(creator) + `` + "\n")
+ b.WriteString(`` + created + `` + "\n")
+ b.WriteString(`` + modified + `` + "\n")
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + xmlEscape(producer) + `` + "\n")
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + "\n")
+ _, _ = fmt.Fprintf(&b, `%d`, part)
+ b.WriteByte('\n')
+ if part == 4 {
+ b.WriteString(`2020` + "\n")
+ }
+ if conf != "" {
+ b.WriteString(`` + conf + `` + "\n")
+ }
+ b.WriteString(`` + "\n")
+ f.writePdfAXMPExtensionSchemas(&b, level)
+ f.writePdfAXMPPropertyBlocks(&b)
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + "\n")
+ b.WriteString(``)
+ return b.String()
+}
+
+func (f *PDF) writePdfAXMPExtensionSchemas(b *strings.Builder, level PdfALevel) {
+ emitAFSchema := allowsPdfAAttachments(level)
+ if !emitAFSchema && len(f.pdfA.XMPSchemas) == 0 {
+ return
+ }
+
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + "\n")
+ if emitAFSchema {
+ writePdfAAssociatedFileSchema(b)
+ }
+ for _, schema := range f.pdfA.XMPSchemas {
+ writePdfAXMPSchema(b, schema)
+ }
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + "\n")
+}
+
+func writePdfAAssociatedFileSchema(b *strings.Builder) {
+ b.WriteString(``)
+ b.WriteString(`PDF/A Associated File Attachment`)
+ b.WriteString(`http://www.aiim.org/pdfa/ns/f#`)
+ b.WriteString(`pdfaf`)
+ b.WriteString(``)
+ b.WriteString(`file`)
+ b.WriteString(`URI`)
+ b.WriteString(`external`)
+ b.WriteString(`Associated file`)
+ b.WriteString(``)
+ b.WriteString(`` + "\n")
+}
+
+func writePdfAXMPSchema(b *strings.Builder, schema XMPSchema) {
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + xmlEscape(schema.Schema) + `` + "\n")
+ b.WriteString(`` + xmlEscape(schema.NamespaceURI) + `` + "\n")
+ b.WriteString(`` + xmlEscape(schema.Prefix) + `` + "\n")
+ if len(schema.Properties) > 0 {
+ b.WriteString(`` + "\n")
+ for _, property := range schema.Properties {
+ writePdfAXMPSchemaProperty(b, property)
+ }
+ b.WriteString(`` + "\n")
+ }
+ b.WriteString(`` + "\n")
+}
+
+func writePdfAXMPSchemaProperty(b *strings.Builder, property XMPSchemaProperty) {
+ b.WriteString(`` + "\n")
+ b.WriteString(`` + xmlEscape(property.Name) + `` + "\n")
+ b.WriteString(`` + xmlEscape(property.ValueType) + `` + "\n")
+ b.WriteString(`` + xmlEscape(property.Category) + `` + "\n")
+ b.WriteString(`` + xmlEscape(property.Description) + `` + "\n")
+ b.WriteString(`` + "\n")
+}
+
+func (f *PDF) writePdfAXMPPropertyBlocks(b *strings.Builder) {
+ for _, block := range f.pdfA.XMPProperties {
+ b.WriteString(`` + "\n")
+ for _, property := range block.Properties {
+ b.WriteString(`<` + block.Prefix + `:` + property.Name + `>`)
+ b.WriteString(xmlEscape(property.Value))
+ b.WriteString(`` + block.Prefix + `:` + property.Name + `>` + "\n")
+ }
+ b.WriteString(`` + "\n")
+ }
+}
+
+func cloneXMPSchemas(schemas []XMPSchema) []XMPSchema {
+ if len(schemas) == 0 {
+ return nil
+ }
+ clones := make([]XMPSchema, len(schemas))
+ for i, schema := range schemas {
+ clones[i] = schema
+ clones[i].Properties = append([]XMPSchemaProperty(nil), schema.Properties...)
+ }
+ return clones
+}
+
+func cloneXMPPropertyBlocks(blocks []XMPPropertyBlock) []XMPPropertyBlock {
+ if len(blocks) == 0 {
+ return nil
+ }
+ clones := make([]XMPPropertyBlock, len(blocks))
+ for i, block := range blocks {
+ clones[i] = block
+ clones[i].Properties = append([]XMPProperty(nil), block.Properties...)
+ }
+ return clones
+}
+
+func (f *PDF) putPdfAOutputIntent() {
+ f.outputIntentObj = 0
+ if f.pdfA == nil {
+ return
+ }
+ profile := f.pdfA.ICCProfile
+ if len(profile) == 0 {
+ profile = srgbICCProfile()
+ }
+ condition := f.pdfA.OutputCondition
+ if condition == "" {
+ condition = "sRGB IEC61966-2.1"
+ }
+
+ f.newobj()
+ profileRef := f.n
+ stream := f.encryptedStream(profile)
+ if f.err != nil {
+ return
+ }
+ f.out("<<")
+ f.out("/N 3")
+ f.outf("/Length %d", len(stream))
+ f.out(">>")
+ f.putstream(stream)
+ f.out("endobj")
+
+ f.newobj()
+ f.outputIntentObj = f.n
+ f.out("<<")
+ f.out("/Type /OutputIntent")
+ f.out("/S /GTS_PDFA1")
+ f.outf("/OutputConditionIdentifier %s", f.textstring(condition))
+ f.out("/RegistryName (http://www.color.org)")
+ f.outf("/Info %s", f.textstring(condition))
+ f.outf("/DestOutputProfile %d 0 R", profileRef)
+ f.out(">>")
+ f.out("endobj")
+}
+
+func xmlEscape(s string) string {
+ s = strings.ReplaceAll(s, "&", "&")
+ s = strings.ReplaceAll(s, "<", "<")
+ s = strings.ReplaceAll(s, ">", ">")
+ s = strings.ReplaceAll(s, `"`, """)
+ return s
+}
diff --git a/internal/pdf/pdfa_icc.go b/internal/pdf/pdfa_icc.go
new file mode 100644
index 00000000..9b12441f
--- /dev/null
+++ b/internal/pdf/pdfa_icc.go
@@ -0,0 +1,163 @@
+package pdf
+
+import (
+ "encoding/binary"
+ "math"
+)
+
+// srgbICCProfile returns a complete sRGB IEC61966-2.1 ICC v2 profile.
+func srgbICCProfile() []byte {
+ const (
+ headerSize = 128
+ tagCount = 9
+ tagTableOff = headerSize
+ tagTableSz = 4 + tagCount*12
+ )
+ dataOff := tagTableOff + tagTableSz
+
+ descData := iccTextDescriptionTag("sRGB IEC61966-2.1")
+ cprtData := iccTextTag("Public Domain")
+ wtptData := iccXYZTag(0.9504559, 1.0000000, 1.0890577)
+ rXYZData := iccXYZTag(0.4360747, 0.2225045, 0.0139322)
+ gXYZData := iccXYZTag(0.3850649, 0.7168786, 0.0971045)
+ bXYZData := iccXYZTag(0.1430804, 0.0606169, 0.7141733)
+ trcData := iccSRGBCurveTag()
+
+ tags := []iccTagLayout{
+ {"desc", descData},
+ {"cprt", cprtData},
+ {"wtpt", wtptData},
+ {"rXYZ", rXYZData},
+ {"gXYZ", gXYZData},
+ {"bXYZ", bXYZData},
+ {"rTRC", trcData},
+ }
+
+ offsets, profileSize := iccTagOffsets(tags, dataOff)
+ trcOff := offsets[6]
+ trcSize := len(trcData)
+ profile := make([]byte, profileSize)
+
+ iccPutUint32(profile[0:4], profileSize)
+ profile[8] = 2
+ profile[9] = 0x10
+ copy(profile[12:16], "mntr")
+ copy(profile[16:20], "RGB ")
+ copy(profile[20:24], "XYZ ")
+ binary.BigEndian.PutUint16(profile[24:26], 2024)
+ binary.BigEndian.PutUint16(profile[26:28], 1)
+ binary.BigEndian.PutUint16(profile[28:30], 1)
+ copy(profile[36:40], "acsp")
+ copy(profile[40:44], "APPL")
+ iccPutS15Fixed16(profile[68:72], 0.9504559)
+ iccPutS15Fixed16(profile[72:76], 1.0000000)
+ iccPutS15Fixed16(profile[76:80], 1.0890577)
+
+ iccPutUint32(profile[tagTableOff:], tagCount)
+ entries := []iccTagEntry{
+ {"desc", offsets[0], len(tags[0].data)},
+ {"cprt", offsets[1], len(tags[1].data)},
+ {"wtpt", offsets[2], len(tags[2].data)},
+ {"rXYZ", offsets[3], len(tags[3].data)},
+ {"gXYZ", offsets[4], len(tags[4].data)},
+ {"bXYZ", offsets[5], len(tags[5].data)},
+ {"rTRC", trcOff, trcSize},
+ {"gTRC", trcOff, trcSize},
+ {"bTRC", trcOff, trcSize},
+ }
+ for i, entry := range entries {
+ p := tagTableOff + 4 + i*12
+ copy(profile[p:p+4], entry.sig)
+ iccPutUint32(profile[p+4:p+8], entry.offset)
+ iccPutUint32(profile[p+8:p+12], entry.size)
+ }
+ for i, tag := range tags {
+ copy(profile[offsets[i]:], tag.data)
+ }
+ return profile
+}
+
+type iccTagLayout struct {
+ sig string
+ data []byte
+}
+
+type iccTagEntry struct {
+ sig string
+ offset int
+ size int
+}
+
+func iccTagOffsets(tags []iccTagLayout, dataOff int) ([]int, int) {
+ offsets := make([]int, len(tags))
+ offset := dataOff
+ for i, tag := range tags {
+ offsets[i] = offset
+ offset += len(tag.data)
+ if offset%4 != 0 {
+ offset += 4 - offset%4
+ }
+ }
+ return offsets, offset
+}
+
+func iccPutUint32(b []byte, value int) {
+ checked, _ := checkedUint32(value)
+ binary.BigEndian.PutUint32(b, checked)
+}
+
+func iccPutS15Fixed16(b []byte, value float64) {
+ fixed := int32(math.Round(value * 65536))
+ binary.BigEndian.PutUint32(b, uint32(fixed)) // #nosec G115 -- ICC s15Fixed16 stores signed bits in a uint32 field.
+}
+
+func iccXYZTag(x, y, z float64) []byte {
+ data := make([]byte, 20)
+ copy(data[0:4], "XYZ ")
+ iccPutS15Fixed16(data[8:12], x)
+ iccPutS15Fixed16(data[12:16], y)
+ iccPutS15Fixed16(data[16:20], z)
+ return data
+}
+
+func iccTextDescriptionTag(value string) []byte {
+ ascii := []byte(value)
+ asciiLen := len(ascii) + 1
+ size := 4 + 4 + 4 + asciiLen + 4 + 4 + 2 + 1 + 67
+ data := make([]byte, size)
+ copy(data[0:4], "desc")
+ iccPutUint32(data[8:12], asciiLen)
+ copy(data[12:12+len(ascii)], ascii)
+ return data
+}
+
+func iccTextTag(value string) []byte {
+ ascii := []byte(value)
+ data := make([]byte, 4+4+len(ascii)+1)
+ copy(data[0:4], "text")
+ copy(data[8:], ascii)
+ return data
+}
+
+func iccSRGBCurveTag() []byte {
+ const entries = 1024
+ data := make([]byte, 4+4+4+entries*2)
+ copy(data[0:4], "curv")
+ iccPutUint32(data[8:12], entries)
+ for i := range entries {
+ t := float64(i) / float64(entries-1)
+ linear := iccSRGBLinearValue(t)
+ rounded := int(math.Round(linear * 65535))
+ value, _ := checkedUint16(rounded)
+ offset := 12 + i*2
+ binary.BigEndian.PutUint16(data[offset:offset+2], value)
+ }
+ return data
+}
+
+func iccSRGBLinearValue(encoded float64) float64 {
+ if encoded <= 0.04045 {
+ return encoded / 12.92
+ }
+ return math.Pow((encoded+0.055)/1.055, 2.4)
+}
diff --git a/internal/pdf/pdfa_icc_test.go b/internal/pdf/pdfa_icc_test.go
new file mode 100644
index 00000000..01e629d6
--- /dev/null
+++ b/internal/pdf/pdfa_icc_test.go
@@ -0,0 +1,31 @@
+package pdf
+
+import (
+ "encoding/binary"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+)
+
+func TestSRGBICCProfile_ShouldContainValidHeaderAndRequiredTags(t *testing.T) {
+ t.Parallel()
+
+ profile := srgbICCProfile()
+
+ assert.Greater(t, len(profile), 2000)
+ assert.Equal(t, uint32(len(profile)), binary.BigEndian.Uint32(profile[0:4]))
+ assert.Equal(t, []byte("mntr"), profile[12:16])
+ assert.Equal(t, []byte("RGB "), profile[16:20])
+ assert.Equal(t, []byte("XYZ "), profile[20:24])
+ assert.Equal(t, []byte("acsp"), profile[36:40])
+ assert.Equal(t, uint32(9), binary.BigEndian.Uint32(profile[128:132]))
+
+ tags := make(map[string]bool)
+ for i := range 9 {
+ offset := 132 + i*12
+ tags[string(profile[offset:offset+4])] = true
+ }
+ for _, tag := range []string{"desc", "cprt", "wtpt", "rXYZ", "gXYZ", "bXYZ", "rTRC", "gTRC", "bTRC"} {
+ assert.True(t, tags[tag], "missing ICC tag %s", tag)
+ }
+}
diff --git a/internal/pdf/pdfa_test.go b/internal/pdf/pdfa_test.go
new file mode 100644
index 00000000..f0d16507
--- /dev/null
+++ b/internal/pdf/pdfa_test.go
@@ -0,0 +1,59 @@
+package pdf
+
+import (
+ "bytes"
+ "regexp"
+ "testing"
+)
+
+func TestSetPdfAEmitsMetadataAndOutputIntent(t *testing.T) {
+ f := readyPDF(t)
+ f.SetTitle("PDF/A Title", false)
+ f.SetPdfA(PdfAConfig{Level: PdfA2B})
+ out := mustOutput(t, f)
+
+ for _, want := range []string{
+ "/OutputIntents [",
+ "/S /GTS_PDFA1",
+ "2",
+ "B",
+ "/Type /Metadata",
+ } {
+ if !bytes.Contains(out, []byte(want)) {
+ t.Fatalf("expected PDF/A output to contain %q", want)
+ }
+ }
+ if n := len(regexp.MustCompile(`/Metadata \d+ 0 R`).FindAll(out, -1)); n != 1 {
+ t.Fatalf("expected exactly one catalog /Metadata reference, found %d", n)
+ }
+}
+
+func TestSetPdfALevelAEnablesTaggedOutput(t *testing.T) {
+ f := NewCustom(&InitType{OrientationStr: "P", UnitStr: "mm", SizeStr: "A4"})
+ f.SetPdfA(PdfAConfig{Level: PdfA1A})
+ f.AddPage()
+ f.SetFont("Helvetica", "", 12)
+ f.Cell(40, 10, "accessible")
+ out := mustOutput(t, f)
+
+ if !bytes.Contains(out, []byte("A")) {
+ t.Fatal("missing level A conformance in XMP")
+ }
+ if !regexp.MustCompile(`/StructTreeRoot \d+ 0 R`).Match(out) {
+ t.Fatal("level A PDF/A must emit a structure tree")
+ }
+}
+
+func TestSetPdfAOverridesCustomXmpWithSingleMetadataRef(t *testing.T) {
+ f := readyPDF(t)
+ f.SetXmpMetadata([]byte(``))
+ f.SetPdfA(PdfAConfig{Level: PdfA3B})
+ out := mustOutput(t, f)
+
+ if !bytes.Contains(out, []byte("3")) {
+ t.Fatal("expected the PDF/A XMP packet to replace the custom XMP stream")
+ }
+ if n := len(regexp.MustCompile(`/Metadata \d+ 0 R`).FindAll(out, -1)); n != 1 {
+ t.Fatalf("expected exactly one catalog /Metadata reference, found %d", n)
+ }
+}
diff --git a/internal/pdf/protect.go b/internal/pdf/protect.go
index edbc7437..8d2085a4 100644
--- a/internal/pdf/protect.go
+++ b/internal/pdf/protect.go
@@ -47,16 +47,15 @@ type protectType struct {
objNum int
fileID []byte
random io.Reader
- rc4cipher *rc4.Cipher
- rc4n uint32 // Object number associated with rc4 cipher
}
+// rc4 encrypts buf in place. The PDF standard security handler encrypts every
+// string and stream independently, so a fresh cipher (and keystream) must be
+// created for each call; reusing a cipher across strings of the same object
+// would continue the keystream and corrupt every string after the first.
func (p *protectType) rc4(n uint32, buf *[]byte) {
- if p.rc4cipher == nil || p.rc4n != n {
- p.rc4cipher, _ = rc4.NewCipher(p.objectKey(n)) // #nosec G405 -- required by the PDF security handler.
- p.rc4n = n
- }
- p.rc4cipher.XORKeyStream(*buf, *buf)
+ c, _ := rc4.NewCipher(p.objectKey(n)) // #nosec G405 -- required by the PDF security handler.
+ c.XORKeyStream(*buf, *buf)
}
func (p *protectType) objectKey(n uint32) []byte {
diff --git a/internal/pdf/protect_test.go b/internal/pdf/protect_test.go
index 82f03e37..01c48eb1 100644
--- a/internal/pdf/protect_test.go
+++ b/internal/pdf/protect_test.go
@@ -4,6 +4,9 @@ import (
"bytes"
"crypto/aes"
"crypto/cipher"
+ "crypto/rc4" // #nosec G503 -- tests decrypt PDF standard security handler output.
+ "regexp"
+ "strconv"
"strings"
"testing"
)
@@ -105,6 +108,117 @@ func TestProtectionAES128EncryptsWithIVAndPKCS7Padding(t *testing.T) {
}
}
+func TestProtectionRC4EncryptsEachStringIndependently(t *testing.T) {
+ t.Parallel()
+
+ var p protectType
+ p.setProtection(CnProtectCopy, "user", "owner")
+
+ const objNum = 7
+ first, err := p.encryptBytes(objNum, []byte("first string"))
+ if err != nil {
+ t.Fatalf("encrypt first: %v", err)
+ }
+ second, err := p.encryptBytes(objNum, []byte("second string"))
+ if err != nil {
+ t.Fatalf("encrypt second: %v", err)
+ }
+
+ if got := rc4DecryptWithObjectKey(t, &p, objNum, first); got != "first string" {
+ t.Fatalf("first string decrypted to %q", got)
+ }
+ if got := rc4DecryptWithObjectKey(t, &p, objNum, second); got != "second string" {
+ t.Fatalf("second string decrypted to %q; RC4 keystream must restart per string", got)
+ }
+}
+
+func TestProtectionRC4InfoStringsRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ f := newProtectionTestPDF()
+ f.SetTitle("Secret Title", false)
+ f.SetAuthor("Secret Author", false)
+ f.SetProtection(CnProtectCopy, "user", "owner")
+
+ var out bytes.Buffer
+ if err := f.Output(&out); err != nil {
+ t.Fatalf("output protected pdf: %v", err)
+ }
+
+ pdf := out.Bytes()
+ infoMatch := regexp.MustCompile(`/Info (\d+) 0 R`).FindSubmatch(pdf)
+ if infoMatch == nil {
+ t.Fatal("missing /Info reference in trailer")
+ }
+ infoNum, err := strconv.Atoi(string(infoMatch[1]))
+ if err != nil {
+ t.Fatalf("parse info object number: %v", err)
+ }
+ objStart := bytes.Index(pdf, []byte("\n"+string(infoMatch[1])+" 0 obj"))
+ if objStart < 0 {
+ t.Fatalf("info object %d not found", infoNum)
+ }
+ infoObj := pdf[objStart:]
+ if end := bytes.Index(infoObj, []byte("endobj")); end >= 0 {
+ infoObj = infoObj[:end]
+ }
+
+ objNum, ok := checkedUint32(infoNum)
+ if !ok {
+ t.Fatalf("info object number out of range: %d", infoNum)
+ }
+ title := rc4DecryptWithObjectKey(t, &f.protect, objNum, extractPDFLiteralString(t, infoObj, "/Title "))
+ if title != "Secret Title" {
+ t.Fatalf("decrypted /Title = %q", title)
+ }
+ author := rc4DecryptWithObjectKey(t, &f.protect, objNum, extractPDFLiteralString(t, infoObj, "/Author "))
+ if author != "Secret Author" {
+ t.Fatalf("decrypted /Author = %q; each Info string must use a fresh RC4 keystream", author)
+ }
+}
+
+// extractPDFLiteralString returns the unescaped bytes of the literal string
+// that follows key, e.g. `/Title (...)`.
+func extractPDFLiteralString(t *testing.T, obj []byte, key string) []byte {
+ t.Helper()
+ start := bytes.Index(obj, []byte(key+"("))
+ if start < 0 {
+ t.Fatalf("missing %s(...) entry", key)
+ }
+ raw := obj[start+len(key)+1:]
+ var value []byte
+ for i := 0; i < len(raw); i++ {
+ c := raw[i]
+ if c == '\\' && i+1 < len(raw) {
+ i++
+ switch raw[i] {
+ case 'r':
+ value = append(value, '\r')
+ default:
+ value = append(value, raw[i])
+ }
+ continue
+ }
+ if c == ')' {
+ return value
+ }
+ value = append(value, c)
+ }
+ t.Fatalf("unterminated literal string after %s", key)
+ return nil
+}
+
+func rc4DecryptWithObjectKey(t *testing.T, p *protectType, n uint32, data []byte) string {
+ t.Helper()
+ c, err := rc4.NewCipher(p.objectKey(n)) // #nosec G405 -- decrypting PDF security handler output.
+ if err != nil {
+ t.Fatalf("rc4 cipher: %v", err)
+ }
+ out := make([]byte, len(data))
+ c.XORKeyStream(out, data)
+ return string(out)
+}
+
func newProtectionTestPDF() *PDF {
f := NewCustom(&InitType{
OrientationStr: "P",
diff --git a/internal/pdf/tagged.go b/internal/pdf/tagged.go
new file mode 100644
index 00000000..8e788fd0
--- /dev/null
+++ b/internal/pdf/tagged.go
@@ -0,0 +1,106 @@
+package pdf
+
+// SetTaggedPDF enables tagged PDF foundation output.
+func (f *PDF) SetTaggedPDF(enabled bool) {
+ f.taggedPDF = enabled
+ if !enabled {
+ f.structTreeRoot = 0
+ }
+}
+
+func (f *PDF) taggedPageContent(_ int, content []byte) []byte {
+ if !f.taggedPDF {
+ return content
+ }
+ const prefix = "/P << /MCID 0 >> BDC\n"
+ wrapped := make([]byte, 0, len(prefix)+len(content)+6)
+ wrapped = append(wrapped, prefix...)
+ wrapped = append(wrapped, content...)
+ if len(content) > 0 && content[len(content)-1] != '\n' {
+ wrapped = append(wrapped, '\n')
+ }
+ wrapped = append(wrapped, []byte("EMC\n")...)
+ return wrapped
+}
+
+func (f *PDF) putTaggedStructure(pageCount int) {
+ f.structTreeRoot = 0
+ if !f.taggedPDF || pageCount <= 0 {
+ return
+ }
+
+ rootRef := f.n + 1
+ docRef := f.n + 2
+ elemStart := f.n + 3
+ arrayStart := elemStart + pageCount
+ parentTreeRef := arrayStart + pageCount
+
+ f.newobjExpected(rootRef)
+ if f.err != nil {
+ return
+ }
+ f.structTreeRoot = rootRef
+ f.out("<<")
+ f.out("/Type /StructTreeRoot")
+ f.outf("/K %d 0 R", docRef)
+ f.outf("/ParentTree %d 0 R", parentTreeRef)
+ f.out(">>")
+ f.out("endobj")
+
+ f.newobjExpected(docRef)
+ if f.err != nil {
+ return
+ }
+ f.out("<<")
+ f.out("/Type /StructElem")
+ f.out("/S /Document")
+ f.outf("/P %d 0 R", rootRef)
+ f.out("/K [")
+ for pageIndex := range pageCount {
+ f.outf("%d 0 R", elemStart+pageIndex)
+ }
+ f.out("]")
+ f.out(">>")
+ f.out("endobj")
+
+ for pageIndex := range pageCount {
+ elemRef := elemStart + pageIndex
+ pageRef := formPageObjectNumber(pageIndex)
+ f.newobjExpected(elemRef)
+ if f.err != nil {
+ return
+ }
+ f.out("<<")
+ f.out("/Type /StructElem")
+ f.out("/S /P")
+ f.outf("/P %d 0 R", docRef)
+ f.outf("/Pg %d 0 R", pageRef)
+ f.outf("/K << /Type /MCR /Pg %d 0 R /MCID 0 >>", pageRef)
+ f.out(">>")
+ f.out("endobj")
+ }
+
+ for pageIndex := range pageCount {
+ arrayRef := arrayStart + pageIndex
+ elemRef := elemStart + pageIndex
+ f.newobjExpected(arrayRef)
+ if f.err != nil {
+ return
+ }
+ f.outf("[%d 0 R]", elemRef)
+ f.out("endobj")
+ }
+
+ f.newobjExpected(parentTreeRef)
+ if f.err != nil {
+ return
+ }
+ f.out("<<")
+ f.out("/Nums [")
+ for pageIndex := range pageCount {
+ f.outf("%d %d 0 R", pageIndex, arrayStart+pageIndex)
+ }
+ f.out("]")
+ f.out(">>")
+ f.out("endobj")
+}
diff --git a/internal/pdf/tagged_test.go b/internal/pdf/tagged_test.go
new file mode 100644
index 00000000..d3af9005
--- /dev/null
+++ b/internal/pdf/tagged_test.go
@@ -0,0 +1,50 @@
+package pdf
+
+import (
+ "bytes"
+ "regexp"
+ "testing"
+)
+
+func TestTaggedPDFEmitsStructureTree(t *testing.T) {
+ f := NewCustom(&InitType{OrientationStr: "P", UnitStr: "mm", SizeStr: "A4"})
+ f.SetCompression(false)
+ f.SetTaggedPDF(true)
+ f.AddPage()
+ f.SetFont("Helvetica", "", 12)
+ f.Cell(40, 10, "page one")
+ f.AddPage()
+ f.Cell(40, 10, "page two")
+ out := mustOutput(t, f)
+
+ if !bytes.Contains(out, []byte("/MarkInfo << /Marked true >>")) {
+ t.Fatal("catalog missing /MarkInfo << /Marked true >>")
+ }
+ if !regexp.MustCompile(`/StructTreeRoot \d+ 0 R`).Match(out) {
+ t.Fatal("catalog missing /StructTreeRoot reference")
+ }
+ for _, want := range []string{
+ "/Type /StructTreeRoot",
+ "/S /Document",
+ "/ParentTree",
+ "/StructParents 0",
+ "/StructParents 1",
+ "/P << /MCID 0 >> BDC",
+ "EMC",
+ } {
+ if !bytes.Contains(out, []byte(want)) {
+ t.Fatalf("expected tagged PDF output to contain %q", want)
+ }
+ }
+}
+
+func TestUntaggedPDFHasNoStructureTree(t *testing.T) {
+ f := readyPDF(t)
+ out := mustOutput(t, f)
+ if bytes.Contains(out, []byte("/StructTreeRoot")) {
+ t.Fatal("unexpected /StructTreeRoot without tagged PDF")
+ }
+ if bytes.Contains(out, []byte("/MarkInfo")) {
+ t.Fatal("unexpected /MarkInfo without tagged PDF")
+ }
+}
diff --git a/internal/pdf/text.go b/internal/pdf/text.go
index ecb76486..9bb9b555 100644
--- a/internal/pdf/text.go
+++ b/internal/pdf/text.go
@@ -6,6 +6,7 @@ import (
"sort"
"strings"
"unicode"
+ "unicode/utf8"
)
// GetStringWidth returns the length of a string in user units. A font must be
@@ -1174,12 +1175,26 @@ func (f *PDF) Bookmark(txtStr string, level int, y float64) {
if y == -1 {
y = f.y
}
- if f.isCurrentUTF8 {
+ if f.isCurrentUTF8 || bookmarkTitleNeedsUTF16(txtStr) {
txtStr = utf8toutf16(txtStr)
}
f.outlines = append(f.outlines, outlineType{text: txtStr, level: level, y: y, p: f.PageNo(), prev: -1, last: -1, next: -1, first: -1})
}
+// bookmarkTitleNeedsUTF16 reports whether a bookmark title must be encoded as
+// UTF-16BE with BOM. Titles are PDF text strings, not font-encoded strings:
+// raw UTF-8 would be misread as PDFDocEncoding by viewers. Pure ASCII stays a
+// plain string for byte-stability, and byte sequences that are not valid
+// UTF-8 (e.g. legacy ISO-8859-1 input) are kept as-is.
+func bookmarkTitleNeedsUTF16(s string) bool {
+ for i := range len(s) {
+ if s[i] >= 0x80 {
+ return utf8.ValidString(s)
+ }
+ }
+ return false
+}
+
func (f *PDF) putbookmarks() {
nb := len(f.outlines)
if nb == 0 {
@@ -1231,7 +1246,7 @@ func (f *PDF) putBookmarkObjects(n int) {
f.outf("<>")
f.out("endobj")
}
diff --git a/internal/pdf/text_test.go b/internal/pdf/text_test.go
index 7feedf20..92361ce7 100644
--- a/internal/pdf/text_test.go
+++ b/internal/pdf/text_test.go
@@ -1,6 +1,8 @@
package pdf
import (
+ "bytes"
+ "regexp"
"strings"
"testing"
)
@@ -128,6 +130,51 @@ func TestBookmark(t *testing.T) {
}
}
+func TestBookmarkNonASCIITitleUsesUTF16(t *testing.T) {
+ f := readyPDF(t) // core font: isCurrentUTF8 is false
+ f.Bookmark("Résumé", 0, 0)
+ if f.Err() {
+ t.Fatalf("bookmark errored: %v", f.Error())
+ }
+ out := mustOutput(t, f)
+ if !bytes.Contains(out, append([]byte("/Title ("), 0xFE, 0xFF)) {
+ t.Fatal("expected non-ASCII bookmark title to be UTF-16BE encoded with BOM")
+ }
+}
+
+func TestBookmarkASCIITitleStaysPlain(t *testing.T) {
+ f := readyPDF(t)
+ f.Bookmark("Chapter 1", 0, 0)
+ out := mustOutput(t, f)
+ if !bytes.Contains(out, []byte("/Title (Chapter 1)")) {
+ t.Fatal("expected ASCII bookmark title to stay a plain string")
+ }
+}
+
+func TestBookmarkDestUsesOwnPageHeight(t *testing.T) {
+ f := NewCustom(&InitType{OrientationStr: "P", UnitStr: "pt", SizeStr: "A4"})
+ f.AddPage()
+ f.SetFont("Helvetica", "", 12)
+ f.Bookmark("First", 0, 100)
+ f.AddPageFormat("P", SizeType{Wd: 400, Ht: 2000})
+ f.Bookmark("Second", 0, 100)
+ if f.Err() {
+ t.Fatalf("bookmark errored: %v", f.Error())
+ }
+ out := mustOutput(t, f)
+
+ // Page 1 is A4 (841.89 pt tall): dest must use the A4 height, not the
+ // final page's 2000 pt height.
+ if !bytes.Contains(out, []byte("/Dest [3 0 R /XYZ 0 741.89 null]")) {
+ t.Fatalf("bookmark on page 1 should use that page's height, output: %s",
+ regexp.MustCompile(`/Dest [^\n]*`).FindAll(out, -1))
+ }
+ if !bytes.Contains(out, []byte("/Dest [5 0 R /XYZ 0 1900.00 null]")) {
+ t.Fatalf("bookmark on page 2 should use that page's height, output: %s",
+ regexp.MustCompile(`/Dest [^\n]*`).FindAll(out, -1))
+ }
+}
+
func TestWordSpacingAndRenderingMode(t *testing.T) {
f := readyPDF(t)
f.SetWordSpacing(2)
diff --git a/internal/pdf/util.go b/internal/pdf/util.go
index fe9aeea7..62874dfc 100644
--- a/internal/pdf/util.go
+++ b/internal/pdf/util.go
@@ -316,7 +316,26 @@ func isChinese(rune2 rune) bool {
// Condition font family string to PDF name compliance. See section 5.3 (Names)
// in https://resources.infosecinstitute.com/pdf-file-format-basic-structure/
func fontFamilyEscape(familyStr string) string {
- escStr := strings.ReplaceAll(familyStr, " ", "#20")
- // Additional replacements can take place here
- return escStr
+ return pdfNameEscape(familyStr)
+}
+
+// pdfNameEscape escapes a string for use as a PDF name object (PDF 32000-1
+// §7.3.5): every byte outside the regular printable range and every
+// delimiter or '#' is written as #XX.
+func pdfNameEscape(s string) string {
+ var b strings.Builder
+ for i := range len(s) {
+ c := s[i]
+ switch {
+ case c < '!' || c > '~':
+ fmt.Fprintf(&b, "#%02X", c)
+ case c == '#' || c == '/' || c == '%' ||
+ c == '(' || c == ')' || c == '<' || c == '>' ||
+ c == '[' || c == ']' || c == '{' || c == '}':
+ fmt.Fprintf(&b, "#%02X", c)
+ default:
+ b.WriteByte(c)
+ }
+ }
+ return b.String()
}
diff --git a/internal/providers/paper/cellwriter/borderlinestyler.go b/internal/providers/paper/cellwriter/borderlinestyler.go
index 929c51a7..21761227 100644
--- a/internal/providers/paper/cellwriter/borderlinestyler.go
+++ b/internal/providers/paper/cellwriter/borderlinestyler.go
@@ -31,7 +31,13 @@ func (b *BorderLineStyler) Apply(width, height float64, config *entity.Config, p
}
fpdf := asPDF[dashPatternPDF](b.fpdf)
- fpdf.SetDashPattern([]float64{1, 1}, 0)
+ // Dotted uses {0.4,0.4} and dashed {1,1}, matching line.go,
+ // persideborder.go and outlinestyler.go.
+ if prop.LineStyle == consts.LineStyleDotted {
+ fpdf.SetDashPattern([]float64{0.4, 0.4}, 0)
+ } else {
+ fpdf.SetDashPattern([]float64{1, 1}, 0)
+ }
b.GoToNext(width, height, config, prop)
fpdf.SetDashPattern([]float64{1, 0}, 0)
}
diff --git a/internal/providers/paper/cellwriter/borderlinestyler_test.go b/internal/providers/paper/cellwriter/borderlinestyler_test.go
index 93f2b7ed..9ce3a05c 100644
--- a/internal/providers/paper/cellwriter/borderlinestyler_test.go
+++ b/internal/providers/paper/cellwriter/borderlinestyler_test.go
@@ -113,6 +113,30 @@ func TestBorderLineStyler_Apply(t *testing.T) {
sut := cellwriter.NewBorderLineStyler(fpdf)
sut.SetNext(inner)
+ // Act
+ sut.Apply(width, height, cfg, prop)
+ })
+ t.Run("When has prop and line style is dotted, should apply dotted pattern and call next", func(t *testing.T) {
+ t.Parallel()
+ // Regression: dotted borders used the dashed pattern {1,1}. Dotted must
+ // use {0.4,0.4}, matching line.go, persideborder.go and outlinestyler.go.
+ width := 100.0
+ height := 100.0
+ cfg := &entity.Config{}
+ prop := &props.Cell{
+ LineStyle: consts.LineStyleDotted,
+ }
+
+ inner := mocks.NewCellWriter(t)
+ inner.EXPECT().Apply(width, height, cfg, prop).Once()
+
+ fpdf := newPDF(t)
+ fpdf.EXPECT().SetDashPattern([]float64{0.4, 0.4}, 0.0).Once()
+ fpdf.EXPECT().SetDashPattern([]float64{1, 0}, 0.0).Once()
+
+ sut := cellwriter.NewBorderLineStyler(fpdf)
+ sut.SetNext(inner)
+
// Act
sut.Apply(width, height, cfg, prop)
})
diff --git a/internal/providers/paper/cellwriter/color_helpers.go b/internal/providers/paper/cellwriter/color_helpers.go
new file mode 100644
index 00000000..3c96f033
--- /dev/null
+++ b/internal/providers/paper/cellwriter/color_helpers.go
@@ -0,0 +1,47 @@
+package cellwriter
+
+import "github.com/avdoseferovic/paper/pkg/props"
+
+type drawColorSetter interface {
+ SetDrawColor(r, g, b int)
+}
+
+type fillColorSetter interface {
+ SetFillColor(r, g, b int)
+}
+
+type drawCMYKColorSetter interface {
+ SetDrawCMYKColor(c, m, y, k float64)
+}
+
+type fillCMYKColorSetter interface {
+ SetFillCMYKColor(c, m, y, k float64)
+}
+
+func applyDrawColor(pdf drawColorSetter, color *props.Color) {
+ if color == nil {
+ return
+ }
+ if color.CMYK != nil {
+ if cmykPDF, ok := any(pdf).(drawCMYKColorSetter); ok {
+ cmyk := color.CMYK
+ cmykPDF.SetDrawCMYKColor(cmyk.Cyan, cmyk.Magenta, cmyk.Yellow, cmyk.Key)
+ return
+ }
+ }
+ pdf.SetDrawColor(color.Red, color.Green, color.Blue)
+}
+
+func applyFillColor(pdf fillColorSetter, color *props.Color) {
+ if color == nil {
+ return
+ }
+ if color.CMYK != nil {
+ if cmykPDF, ok := any(pdf).(fillCMYKColorSetter); ok {
+ cmyk := color.CMYK
+ cmykPDF.SetFillCMYKColor(cmyk.Cyan, cmyk.Magenta, cmyk.Yellow, cmyk.Key)
+ return
+ }
+ }
+ pdf.SetFillColor(color.Red, color.Green, color.Blue)
+}
diff --git a/internal/providers/paper/cellwriter/colorstyler.go b/internal/providers/paper/cellwriter/colorstyler.go
index 06c26942..4c9c7c35 100644
--- a/internal/providers/paper/cellwriter/colorstyler.go
+++ b/internal/providers/paper/cellwriter/colorstyler.go
@@ -33,9 +33,9 @@ func applyColorStyler(
}
func setDrawColor(pdf colorStylerPDF, color *props.Color) {
- pdf.SetDrawColor(color.Red, color.Green, color.Blue)
+ applyDrawColor(pdf, color)
}
func setFillColor(pdf colorStylerPDF, color *props.Color) {
- pdf.SetFillColor(color.Red, color.Green, color.Blue)
+ applyFillColor(pdf, color)
}
diff --git a/internal/providers/paper/cellwriter/persideborder.go b/internal/providers/paper/cellwriter/persideborder.go
index 4f7a1e65..ff194ced 100644
--- a/internal/providers/paper/cellwriter/persideborder.go
+++ b/internal/providers/paper/cellwriter/persideborder.go
@@ -76,9 +76,9 @@ func (p *perSideBorderStyler) drawSide(
fpdf.SetLineWidth(thickness)
if color != nil {
- fpdf.SetDrawColor(color.Red, color.Green, color.Blue)
+ applyDrawColor(fpdf, color)
} else if prop.BorderColor != nil {
- fpdf.SetDrawColor(prop.BorderColor.Red, prop.BorderColor.Green, prop.BorderColor.Blue)
+ applyDrawColor(fpdf, prop.BorderColor)
}
switch style {
diff --git a/internal/providers/paper/checkbox.go b/internal/providers/paper/checkbox.go
index b361068e..a543ce29 100644
--- a/internal/providers/paper/checkbox.go
+++ b/internal/providers/paper/checkbox.go
@@ -8,9 +8,16 @@ import (
const labelGap = 1.0
+// checkboxLabelBaselineRatio places the label baseline so the cap height is
+// visually centered on the checkbox: the middle of a glyph's cap box sits
+// ~0.35 * fontHeight above the baseline (consistent with the vertical
+// centering in provider_capabilities.go and richtext_render.go).
+const checkboxLabelBaselineRatio = 0.35
+
type Checkbox struct {
- pdf checkboxPDF
- font core.Font
+ pdf checkboxPDF
+ font core.Font
+ defaultCodeTranslator func(string) string
}
// NewCheckbox create a Checkbox.
@@ -40,8 +47,21 @@ func (c *Checkbox) Add(label string, cell *entity.Cell, prop *props.Checkbox) {
fontHeight := c.font.GetHeight(family, style, size)
labelX := x + prop.Size + labelGap
- labelY := y + prop.Size/2 + fontHeight/2
+ labelY := y + prop.Size/2 + fontHeight*checkboxLabelBaselineRatio
- c.pdf.Text(labelX, labelY, label)
+ c.pdf.Text(labelX, labelY, c.translateLabel(label, family))
+ }
+}
+
+// translateLabel applies the code page translation for core font families so
+// non-ASCII labels don't render as mojibake, mirroring Text.translateUnicode.
+// Custom (UTF-8) fonts pass through unchanged.
+func (c *Checkbox) translateLabel(label, family string) string {
+ if !isCoreFontFamily(family) {
+ return label
+ }
+ if c.defaultCodeTranslator == nil {
+ c.defaultCodeTranslator = c.pdf.UnicodeTranslatorFromDescriptor("")
}
+ return c.defaultCodeTranslator(label)
}
diff --git a/internal/providers/paper/checkbox_test.go b/internal/providers/paper/checkbox_test.go
index 4592c93a..986bab6c 100644
--- a/internal/providers/paper/checkbox_test.go
+++ b/internal/providers/paper/checkbox_test.go
@@ -2,6 +2,7 @@ package paper_test
import (
"fmt"
+ "strings"
"testing"
"github.com/avdoseferovic/paper/internal/assert"
@@ -87,17 +88,21 @@ func TestCheckbox_Add(t *testing.T) {
Size: 8,
}
+ fontHeight := 3.0
+ y := 9.0
+
fpdf := newPDF(t)
fpdf.EXPECT().GetMargins().Return(2.0, 3.0, 2.0, 3.0)
+ fpdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string { return s })
// x = 5 + 1 + 2 = 8, y = 5 + 1 + 3 = 9
- fpdf.EXPECT().Rect(8.0, 9.0, 8.0, 8.0, "D")
+ fpdf.EXPECT().Rect(8.0, y, 8.0, 8.0, "D")
// labelX = x + size + gap = 8 + 8 + 1 = 17
- // labelY = y + size/2 + fontHeight/2 = 9 + 4 + 1.5 = 14.5
- fpdf.EXPECT().Text(17.0, 14.5, "label")
+ // labelY centers the cap height on the box: y + size/2 + fontHeight*0.35
+ fpdf.EXPECT().Text(17.0, y+prop.Size/2+fontHeight*0.35, "label")
font := mocks.NewFont(t)
font.EXPECT().GetFont().Return(consts.FontFamilyArial, fontstyle.Normal, 10.0)
- font.EXPECT().GetHeight(consts.FontFamilyArial, fontstyle.Normal, 10.0).Return(3.0)
+ font.EXPECT().GetHeight(consts.FontFamilyArial, fontstyle.Normal, 10.0).Return(fontHeight)
sut := gofpdf.NewCheckbox(fpdf, font)
@@ -115,25 +120,87 @@ func TestCheckbox_Add(t *testing.T) {
Size: 10,
}
+ fontHeight := 4.0
+
fpdf := newPDF(t)
fpdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
+ fpdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string { return s })
// x = 0, y = 0
fpdf.EXPECT().Rect(0.0, 0.0, 10.0, 10.0, "D")
fpdf.EXPECT().Line(0.0, 0.0, 10.0, 10.0)
fpdf.EXPECT().Line(10.0, 0.0, 0.0, 10.0)
// labelX = 0 + 10 + 1 = 11
- // labelY = 0 + 5 + 2 = 7
- fpdf.EXPECT().Text(11.0, 7.0, "option")
+ // labelY = 0 + 10/2 + fontHeight*0.35
+ fpdf.EXPECT().Text(11.0, 0.0+prop.Size/2+fontHeight*0.35, "option")
font := mocks.NewFont(t)
font.EXPECT().GetFont().Return(consts.FontFamilyArial, fontstyle.Normal, 12.0)
- font.EXPECT().GetHeight(consts.FontFamilyArial, fontstyle.Normal, 12.0).Return(4.0)
+ font.EXPECT().GetHeight(consts.FontFamilyArial, fontstyle.Normal, 12.0).Return(fontHeight)
sut := gofpdf.NewCheckbox(fpdf, font)
// Act
sut.Add("option", cell, prop)
})
+ t.Run("when label has non-ASCII chars and font is a core family, should translate the label", func(t *testing.T) {
+ t.Parallel()
+ // Regression: the label was drawn without cp1252 translation, producing
+ // mojibake for non-ASCII text with core fonts.
+ cell := &entity.Cell{X: 0, Y: 0}
+ prop := &props.Checkbox{
+ Checked: false,
+ Top: 0,
+ Left: 0,
+ Size: 10,
+ }
+
+ fontHeight := 4.0
+
+ fpdf := newPDF(t)
+ fpdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
+ fpdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string {
+ return strings.ReplaceAll(s, "é", "\xe9")
+ })
+ fpdf.EXPECT().Rect(0.0, 0.0, 10.0, 10.0, "D")
+ // translated label, not the raw UTF-8 input
+ fpdf.EXPECT().Text(11.0, 0.0+prop.Size/2+fontHeight*0.35, "caf\xe9")
+
+ font := mocks.NewFont(t)
+ font.EXPECT().GetFont().Return(consts.FontFamilyArial, fontstyle.Normal, 12.0)
+ font.EXPECT().GetHeight(consts.FontFamilyArial, fontstyle.Normal, 12.0).Return(fontHeight)
+
+ sut := gofpdf.NewCheckbox(fpdf, font)
+
+ // Act
+ sut.Add("café", cell, prop)
+ })
+ t.Run("when font is a custom UTF-8 family, should not translate the label", func(t *testing.T) {
+ t.Parallel()
+ cell := &entity.Cell{X: 0, Y: 0}
+ prop := &props.Checkbox{
+ Checked: false,
+ Top: 0,
+ Left: 0,
+ Size: 10,
+ }
+
+ fontHeight := 4.0
+
+ fpdf := newPDF(t)
+ fpdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
+ // no UnicodeTranslatorFromDescriptor expectation: custom fonts pass through
+ fpdf.EXPECT().Rect(0.0, 0.0, 10.0, 10.0, "D")
+ fpdf.EXPECT().Text(11.0, 0.0+prop.Size/2+fontHeight*0.35, "café")
+
+ font := mocks.NewFont(t)
+ font.EXPECT().GetFont().Return("roboto", fontstyle.Normal, 12.0)
+ font.EXPECT().GetHeight("roboto", fontstyle.Normal, 12.0).Return(fontHeight)
+
+ sut := gofpdf.NewCheckbox(fpdf, font)
+
+ // Act
+ sut.Add("café", cell, prop)
+ })
t.Run("when margins are set, should offset x and y by margin values", func(t *testing.T) {
t.Parallel()
// Arrange
diff --git a/internal/providers/paper/color.go b/internal/providers/paper/color.go
new file mode 100644
index 00000000..3a4b9d6d
--- /dev/null
+++ b/internal/providers/paper/color.go
@@ -0,0 +1,69 @@
+package paper
+
+import "github.com/avdoseferovic/paper/pkg/props"
+
+type drawColorSetter interface {
+ SetDrawColor(r, g, b int)
+}
+
+type fillColorSetter interface {
+ SetFillColor(r, g, b int)
+}
+
+type textColorSetter interface {
+ SetTextColor(r, g, b int)
+}
+
+type drawCMYKColorSetter interface {
+ SetDrawCMYKColor(c, m, y, k float64)
+}
+
+type fillCMYKColorSetter interface {
+ SetFillCMYKColor(c, m, y, k float64)
+}
+
+type textCMYKColorSetter interface {
+ SetTextCMYKColor(c, m, y, k float64)
+}
+
+func setPDFDrawColor(pdf drawColorSetter, color *props.Color) {
+ if color == nil {
+ return
+ }
+ if color.CMYK != nil {
+ if cmykPDF, ok := any(pdf).(drawCMYKColorSetter); ok {
+ cmyk := color.CMYK
+ cmykPDF.SetDrawCMYKColor(cmyk.Cyan, cmyk.Magenta, cmyk.Yellow, cmyk.Key)
+ return
+ }
+ }
+ pdf.SetDrawColor(color.Red, color.Green, color.Blue)
+}
+
+func setPDFFillColor(pdf fillColorSetter, color *props.Color) {
+ if color == nil {
+ return
+ }
+ if color.CMYK != nil {
+ if cmykPDF, ok := any(pdf).(fillCMYKColorSetter); ok {
+ cmyk := color.CMYK
+ cmykPDF.SetFillCMYKColor(cmyk.Cyan, cmyk.Magenta, cmyk.Yellow, cmyk.Key)
+ return
+ }
+ }
+ pdf.SetFillColor(color.Red, color.Green, color.Blue)
+}
+
+func setPDFTextColor(pdf textColorSetter, color *props.Color) {
+ if color == nil {
+ return
+ }
+ if color.CMYK != nil {
+ if cmykPDF, ok := any(pdf).(textCMYKColorSetter); ok {
+ cmyk := color.CMYK
+ cmykPDF.SetTextCMYKColor(cmyk.Cyan, cmyk.Magenta, cmyk.Yellow, cmyk.Key)
+ return
+ }
+ }
+ pdf.SetTextColor(color.Red, color.Green, color.Blue)
+}
diff --git a/internal/providers/paper/font.go b/internal/providers/paper/font.go
index d16674b1..2a03582a 100644
--- a/internal/providers/paper/font.go
+++ b/internal/providers/paper/font.go
@@ -94,7 +94,7 @@ func (s *Font) SetColor(color *props.Color) {
}
s.fontColor = color
- s.pdf.SetTextColor(color.Red, color.Green, color.Blue)
+ setPDFTextColor(s.pdf, color)
}
func (s *Font) GetColor() *props.Color {
diff --git a/internal/providers/paper/hyphenation.go b/internal/providers/paper/hyphenation.go
new file mode 100644
index 00000000..b1ca8f64
--- /dev/null
+++ b/internal/providers/paper/hyphenation.go
@@ -0,0 +1,102 @@
+package paper
+
+import (
+ "strings"
+ "unicode"
+)
+
+// richTextHyphenator implements the Liang-Knuth hyphenation algorithm used by
+// TeX. It is intentionally provider-internal for now: the public API exposes
+// CSS hyphen controls through HTML/RichText properties rather than a standalone
+// hyphenation package.
+type richTextHyphenator struct {
+ patterns map[string][]int
+}
+
+type Hyphenator = richTextHyphenator
+
+func NewHyphenator(patterns []string) *Hyphenator {
+ return newRichTextHyphenator(patterns)
+}
+
+func newRichTextHyphenator(patterns []string) *richTextHyphenator {
+ h := &richTextHyphenator{
+ patterns: make(map[string][]int, len(patterns)),
+ }
+ for _, p := range patterns {
+ letters, values := parseHyphenationPattern(p)
+ h.patterns[letters] = values
+ }
+ return h
+}
+
+func parseHyphenationPattern(pattern string) (string, []int) {
+ var letters []rune
+ var values []int
+ for _, r := range pattern {
+ if r >= '0' && r <= '9' {
+ for len(values) <= len(letters) {
+ values = append(values, 0)
+ }
+ values[len(letters)] = int(r - '0')
+ continue
+ }
+ letters = append(letters, r)
+ }
+ for len(values) <= len(letters) {
+ values = append(values, 0)
+ }
+ return string(letters), values
+}
+
+func (h *richTextHyphenator) Hyphenate(word string) []int {
+ runes := []rune(strings.ToLower(word))
+ if len(runes) < 4 {
+ return nil
+ }
+
+ wrapped := make([]rune, 0, len(runes)+2)
+ wrapped = append(wrapped, '.')
+ wrapped = append(wrapped, runes...)
+ wrapped = append(wrapped, '.')
+
+ levels := make([]int, len(wrapped)+1)
+ for i := range wrapped {
+ for j := i + 1; j <= len(wrapped); j++ {
+ sub := string(wrapped[i:j])
+ values, ok := h.patterns[sub]
+ if !ok {
+ continue
+ }
+ for k, value := range values {
+ pos := i + k
+ if pos < len(levels) && value > levels[pos] {
+ levels[pos] = value
+ }
+ }
+ }
+ }
+
+ var breaks []int
+ for i := 2; i < len(runes); i++ {
+ pos := i + 1
+ if pos < len(levels) && levels[pos]%2 == 1 && i <= len(runes)-2 {
+ breaks = append(breaks, i)
+ }
+ }
+ return breaks
+}
+
+func defaultRichTextHyphenator() *richTextHyphenator {
+ initDefaultHyphenator()
+ return defaultHyphenator
+}
+
+func isAlphaHyphenationWord(s string) bool {
+ for _, r := range s {
+ if !unicode.IsLetter(r) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/providers/paper/hyphenation_patterns.go b/internal/providers/paper/hyphenation_patterns.go
new file mode 100644
index 00000000..e311f0f3
--- /dev/null
+++ b/internal/providers/paper/hyphenation_patterns.go
@@ -0,0 +1,4986 @@
+// Copyright 2026 Carlos Munoz and the Folio Authors
+// SPDX-License-Identifier: Apache-2.0
+
+// Code generated from hyph-en-us.pat.txt; DO NOT EDIT.
+
+package paper
+
+import "sync"
+
+// enUSPatterns contains the standard TeX US English hyphenation patterns
+// from the Knuth-Liang algorithm. Each line is one pattern.
+const enUSPatterns = `
+.ach4
+.ad4der
+.af1t
+.al3t
+.am5at
+.an5c
+.ang4
+.ani5m
+.ant4
+.an3te
+.anti5s
+.ar5s
+.ar4tie
+.ar4ty
+.as3c
+.as1p
+.as1s
+.aster5
+.atom5
+.au1d
+.av4i
+.awn4
+.ba4g
+.ba5na
+.bas4e
+.ber4
+.be5ra
+.be3sm
+.be5sto
+.bri2
+.but4ti
+.cam4pe
+.can5c
+.capa5b
+.car5ol
+.ca4t
+.ce4la
+.ch4
+.chill5i
+.ci2
+.cit5r
+.co3e
+.co4r
+.cor5ner
+.de4moi
+.de3o
+.de3ra
+.de3ri
+.des4c
+.dictio5
+.do4t
+.du4c
+.dumb5
+.earth5
+.eas3i
+.eb4
+.eer4
+.eg2
+.el5d
+.el3em
+.enam3
+.en3g
+.en3s
+.eq5ui5t
+.er4ri
+.es3
+.eu3
+.eye5
+.fes3
+.for5mer
+.ga2
+.ge2
+.gen3t4
+.ge5og
+.gi5a
+.gi4b
+.go4r
+.hand5i
+.han5k
+.he2
+.hero5i
+.hes3
+.het3
+.hi3b
+.hi3er
+.hon5ey
+.hon3o
+.hov5
+.id4l
+.idol3
+.im3m
+.im5pin
+.in1
+.in3ci
+.ine2
+.in2k
+.in3s
+.ir5r
+.is4i
+.ju3r
+.la4cy
+.la4m
+.lat5er
+.lath5
+.le2
+.leg5e
+.len4
+.lep5
+.lev1
+.li4g
+.lig5a
+.li2n
+.li3o
+.li4t
+.mag5a5
+.mal5o
+.man5a
+.mar5ti
+.me2
+.mer3c
+.me5ter
+.mis1
+.mist5i
+.mon3e
+.mo3ro
+.mu5ta
+.muta5b
+.ni4c
+.od2
+.odd5
+.of5te
+.or5ato
+.or3c
+.or1d
+.or3t
+.os3
+.os4tl
+.oth3
+.out3
+.ped5al
+.pe5te
+.pe5tit
+.pi4e
+.pio5n
+.pi2t
+.pre3m
+.ra4c
+.ran4t
+.ratio5na
+.ree2
+.re5mit
+.res2
+.re5stat
+.ri4g
+.rit5u
+.ro4q
+.ros5t
+.row5d
+.ru4d
+.sci3e
+.self5
+.sell5
+.se2n
+.se5rie
+.sh2
+.si2
+.sing4
+.st4
+.sta5bl
+.sy2
+.ta4
+.te4
+.ten5an
+.th2
+.ti2
+.til4
+.tim5o5
+.ting4
+.tin5k
+.ton4a
+.to4p
+.top5i
+.tou5s
+.trib5ut
+.un1a
+.un3ce
+.under5
+.un1e
+.un5k
+.un5o
+.un3u
+.up3
+.ure3
+.us5a
+.ven4de
+.ve5ra
+.wil5i
+.ye4
+4ab.
+a5bal
+a5ban
+abe2
+ab5erd
+abi5a
+ab5it5ab
+ab5lat
+ab5o5liz
+4abr
+ab5rog
+ab3ul
+a4car
+ac5ard
+ac5aro
+a5ceou
+ac1er
+a5chet
+4a2ci
+a3cie
+ac1in
+a3cio
+ac5rob
+act5if
+ac3ul
+ac4um
+a2d
+ad4din
+ad5er.
+2adi
+a3dia
+ad3ica
+adi4er
+a3dio
+a3dit
+a5diu
+ad4le
+ad3ow
+ad5ran
+ad4su
+4adu
+a3duc
+ad5um
+ae4r
+aeri4e
+a2f
+aff4
+a4gab
+aga4n
+ag5ell
+age4o
+4ageu
+ag1i
+4ag4l
+ag1n
+a2go
+3agog
+ag3oni
+a5guer
+ag5ul
+a4gy
+a3ha
+a3he
+ah4l
+a3ho
+ai2
+a5ia
+a3ic.
+ai5ly
+a4i4n
+ain5in
+ain5o
+ait5en
+a1j
+ak1en
+al5ab
+al3ad
+a4lar
+4aldi
+2ale
+al3end
+a4lenti
+a5le5o
+al1i
+al4ia.
+ali4e
+al5lev
+4allic
+4alm
+a5log.
+a4ly.
+4alys
+5a5lyst
+5alyt
+3alyz
+4ama
+am5ab
+am3ag
+ama5ra
+am5asc
+a4matis
+a4m5ato
+am5era
+am3ic
+am5if
+am5ily
+am1in
+ami4no
+a2mo
+a5mon
+amor5i
+amp5en
+a2n
+an3age
+3analy
+a3nar
+an3arc
+anar4i
+a3nati
+4and
+ande4s
+an3dis
+an1dl
+an4dow
+a5nee
+a3nen
+an5est.
+a3neu
+2ang
+ang5ie
+an1gl
+a4n1ic
+a3nies
+an3i3f
+an4ime
+a5nimi
+a5nine
+an3io
+a3nip
+an3ish
+an3it
+a3niu
+an4kli
+5anniz
+ano4
+an5ot
+anoth5
+an2sa
+an4sco
+an4sn
+an2sp
+ans3po
+an4st
+an4sur
+antal4
+an4tie
+4anto
+an2tr
+an4tw
+an3ua
+an3ul
+a5nur
+4ao
+apar4
+ap5at
+ap5ero
+a3pher
+4aphi
+a4pilla
+ap5illar
+ap3in
+ap3ita
+a3pitu
+a2pl
+apoc5
+ap5ola
+apor5i
+apos3t
+aps5es
+a3pu
+aque5
+2a2r
+ar3act
+a5rade
+ar5adis
+ar3al
+a5ramete
+aran4g
+ara3p
+ar4at
+a5ratio
+ar5ativ
+a5rau
+ar5av4
+araw4
+arbal4
+ar4chan
+ar5dine
+ar4dr
+ar5eas
+a3ree
+ar3ent
+a5ress
+ar4fi
+ar4fl
+ar1i
+ar5ial
+ar3ian
+a3riet
+ar4im
+ar5inat
+ar3io
+ar2iz
+ar2mi
+ar5o5d
+a5roni
+a3roo
+ar2p
+ar3q
+arre4
+ar4sa
+ar2sh
+4as.
+as4ab
+as3ant
+ashi4
+a5sia.
+a3sib
+a3sic
+5a5si4t
+ask3i
+as4l
+a4soc
+as5ph
+as4sh
+as3ten
+as1tr
+asur5a
+a2ta
+at3abl
+at5ac
+at3alo
+at5ap
+ate5c
+at5ech
+at3ego
+at3en.
+at3era
+ater5n
+a5terna
+at3est
+at5ev
+4ath
+ath5em
+a5then
+at4ho
+ath5om
+4ati.
+a5tia
+at5i5b
+at1ic
+at3if
+ation5ar
+at3itu
+a4tog
+a2tom
+at5omiz
+a4top
+a4tos
+a1tr
+at5rop
+at4sk
+at4tag
+at5te
+at4th
+a2tu
+at5ua
+at5ue
+at3ul
+at3ura
+a2ty
+au4b
+augh3
+au3gu
+au4l2
+aun5d
+au3r
+au5sib
+aut5en
+au1th
+a2va
+av3ag
+a5van
+ave4no
+av3era
+av5ern
+av5ery
+av1i
+avi4er
+av3ig
+av5oc
+a1vor
+3away
+aw3i
+aw4ly
+aws4
+ax4ic
+ax4id
+ay5al
+aye4
+ays4
+azi4er
+azz5i
+5ba.
+bad5ger
+ba4ge
+bal1a
+ban5dag
+ban4e
+ban3i
+barbi5
+bari4a
+bas4si
+1bat
+ba4z
+2b1b
+b2be
+b3ber
+bbi4na
+4b1d
+4be.
+beak4
+beat3
+4be2d
+be3da
+be3de
+be3di
+be3gi
+be5gu
+1bel
+be1li
+be3lo
+4be5m
+be5nig
+be5nu
+4bes4
+be3sp
+be5str
+3bet
+bet5iz
+be5tr
+be3tw
+be3w
+be5yo
+2bf
+4b3h
+bi2b
+bi4d
+3bie
+bi5en
+bi4er
+2b3if
+1bil
+bi3liz
+bina5r4
+bin4d
+bi5net
+bi3ogr
+bi5ou
+bi2t
+3bi3tio
+bi3tr
+3bit5ua
+b5itz
+b1j
+bk4
+b2l2
+blath5
+b4le.
+blen4
+5blesp
+b3lis
+b4lo
+blun4t
+4b1m
+4b3n
+bne5g
+3bod
+bod3i
+bo4e
+bol3ic
+bom4bi
+bon4a
+bon5at
+3boo
+5bor.
+4b1ora
+bor5d
+5bore
+5bori
+5bos4
+b5ota
+both5
+bo4to
+bound3
+4bp
+4brit
+broth3
+2b5s2
+bsor4
+2bt
+bt4l
+b4to
+b3tr
+buf4fer
+bu4ga
+bu3li
+bumi4
+bu4n
+bunt4i
+bu3re
+bus5ie
+buss4e
+5bust
+4buta
+3butio
+b5uto
+b1v
+4b5w
+5by.
+bys4
+1ca
+cab3in
+ca1bl
+cach4
+ca5den
+4cag4
+2c5ah
+ca3lat
+cal4la
+call5in
+4calo
+can5d
+can4e
+can4ic
+can5is
+can3iz
+can4ty
+cany4
+ca5per
+car5om
+cast5er
+cas5tig
+4casy
+ca4th
+4cativ
+cav5al
+c3c
+ccha5
+cci4a
+ccompa5
+ccon4
+ccou3t
+2ce.
+4ced.
+4ceden
+3cei
+5cel.
+3cell
+1cen
+3cenc
+2cen4e
+4ceni
+3cent
+3cep
+ce5ram
+4cesa
+3cessi
+ces5si5b
+ces5t
+cet4
+c5e4ta
+cew4
+2ch
+4ch.
+4ch3ab
+5chanic
+ch5a5nis
+che2
+cheap3
+4ched
+che5lo
+3chemi
+ch5ene
+ch3er.
+ch3ers
+4ch1in
+5chine.
+ch5iness
+5chini
+5chio
+3chit
+chi2z
+3cho2
+ch4ti
+1ci
+3cia
+ci2a5b
+cia5r
+ci5c
+4cier
+5cific.
+4cii
+ci4la
+3cili
+2cim
+2cin
+c4ina
+3cinat
+cin3em
+c1ing
+c5ing.
+5cino
+cion4
+4cipe
+ci3ph
+4cipic
+4cista
+4cisti
+2c1it
+cit3iz
+5ciz
+ck1
+ck3i
+1c4l4
+4clar
+c5laratio
+5clare
+cle4m
+4clic
+clim4
+cly4
+c5n
+1co
+co5ag
+coe2
+2cog
+co4gr
+coi4
+co3inc
+col5i
+5colo
+col3or
+com5er
+con4a
+c4one
+con3g
+con5t
+co3pa
+cop3ic
+co4pl
+4corb
+coro3n
+cos4e
+cov1
+cove4
+cow5a
+coz5e
+co5zi
+c1q
+cras5t
+5crat.
+5cratic
+cre3at
+5cred
+4c3reta
+cre4v
+cri2
+cri5f
+c4rin
+cris4
+5criti
+cro4pl
+crop5o
+cros4e
+cru4d
+4c3s2
+2c1t
+cta4b
+ct5ang
+c5tant
+c2te
+c3ter
+c4ticu
+ctim3i
+ctu4r
+c4tw
+cud5
+c4uf
+c4ui
+cu5ity
+5culi
+cul4tis
+3cultu
+cu2ma
+c3ume
+cu4mi
+3cun
+cu3pi
+cu5py
+cur5a4b
+cu5ria
+1cus
+cuss4i
+3c4ut
+cu4tie
+4c5utiv
+4cutr
+1cy
+cze4
+1d2a
+5da.
+2d3a4b
+dach4
+4daf
+2dag
+da2m2
+dan3g
+dard5
+dark5
+4dary
+3dat
+4dativ
+4dato
+5dav4
+dav5e
+5day
+d1b
+d5c
+d1d4
+2de.
+deaf5
+deb5it
+de4bon
+decan4
+de4cil
+de5com
+2d1ed
+4dee.
+de5if
+deli4e
+del5i5q
+de5lo
+d4em
+5dem.
+3demic
+dem5ic.
+de5mil
+de4mons
+demor5
+1den
+de4nar
+de3no
+denti5f
+de3nu
+de1p
+de3pa
+depi4
+de2pu
+d3eq
+d4erh
+5derm
+dern5iz
+der5s
+des2
+d2es.
+de1sc
+de2s5o
+des3ti
+de3str
+de4su
+de1t
+de2to
+de1v
+dev3il
+4dey
+4d1f
+d4ga
+d3ge4t
+dg1i
+d2gy
+d1h2
+5di.
+1d4i3a
+dia5b
+di4cam
+d4ice
+3dict
+3did
+5di3en
+d1if
+di3ge
+di4lato
+d1in
+1dina
+3dine.
+5dini
+di5niz
+1dio
+dio5g
+di4pl
+dir2
+di1re
+dirt5i
+dis1
+5disi
+d4is3t
+d2iti
+1di1v
+d1j
+d5k2
+4d5la
+3dle.
+3dled
+3dles.
+4dless
+2d3lo
+4d5lu
+2dly
+d1m
+4d1n4
+1do
+3do.
+do5de
+5doe
+2d5of
+d4og
+do4la
+doli4
+do5lor
+dom5iz
+do3nat
+doni4
+doo3d
+dop4p
+d4or
+3dos
+4d5out
+do4v
+3dox
+d1p
+1dr
+drag5on
+4drai
+dre4
+drea5r
+5dren
+dri4b
+dril4
+dro4p
+4drow
+5drupli
+4dry
+2d1s2
+ds4p
+d4sw
+d4sy
+d2th
+1du
+d1u1a
+du2c
+d1uca
+duc5er
+4duct.
+4ducts
+du5el
+du4g
+d3ule
+dum4be
+du4n
+4dup
+du4pe
+d1v
+d1w
+d2y
+5dyn
+dy4se
+dys5p
+e1a4b
+e3act
+ead1
+ead5ie
+ea4ge
+ea5ger
+ea4l
+eal5er
+eal3ou
+eam3er
+e5and
+ear3a
+ear4c
+ear5es
+ear4ic
+ear4il
+ear5k
+ear2t
+eart3e
+ea5sp
+e3ass
+east3
+ea2t
+eat5en
+eath3i
+e5atif
+e4a3tu
+ea2v
+eav3en
+eav5i
+eav5o
+2e1b
+e4bel.
+e4bels
+e4ben
+e4bit
+e3br
+e4cad
+ecan5c
+ecca5
+e1ce
+ec5essa
+ec2i
+e4cib
+ec5ificat
+ec5ifie
+ec5ify
+ec3im
+eci4t
+e5cite
+e4clam
+e4clus
+e2col
+e4comm
+e4compe
+e4conc
+e2cor
+ec3ora
+eco5ro
+e1cr
+e4crem
+ec4tan
+ec4te
+e1cu
+e4cul
+ec3ula
+2e2da
+4ed3d
+e4d1er
+ede4s
+4edi
+e3dia
+ed3ib
+ed3ica
+ed3im
+ed1it
+edi5z
+4edo
+e4dol
+edon2
+e4dri
+e4dul
+ed5ulo
+ee2c
+eed3i
+ee2f
+eel3i
+ee4ly
+ee2m
+ee4na
+ee4p1
+ee2s4
+eest4
+ee4ty
+e5ex
+e1f
+e4f3ere
+1eff
+e4fic
+5efici
+efil4
+e3fine
+ef5i5nite
+3efit
+efor5es
+e4fuse.
+4egal
+eger4
+eg5ib
+eg4ic
+eg5ing
+e5git5
+eg5n
+e4go.
+e4gos
+eg1ul
+e5gur
+5egy
+e1h4
+eher4
+ei2
+e5ic
+ei5d
+eig2
+ei5gl
+e3imb
+e3inf
+e1ing
+e5inst
+eir4d
+eit3e
+ei3th
+e5ity
+e1j
+e4jud
+ej5udi
+eki4n
+ek4la
+e1la
+e4la.
+e4lac
+elan4d
+el5ativ
+e4law
+elaxa4
+e3lea
+el5ebra
+5elec
+e4led
+el3ega
+e5len
+e4l1er
+e1les
+el2f
+el2i
+e3libe
+e4l5ic.
+el3ica
+e3lier
+el5igib
+e5lim
+e4l3ing
+e3lio
+e2lis
+el5ish
+e3liv3
+4ella
+el4lab
+ello4
+e5loc
+el5og
+el3op.
+el2sh
+el4ta
+e5lud
+el5ug
+e4mac
+e4mag
+e5man
+em5ana
+em5b
+e1me
+e2mel
+e4met
+em3ica
+emi4e
+em5igra
+em1in2
+em5ine
+em3i3ni
+e4mis
+em5ish
+e5miss
+em3iz
+5emniz
+emo4g
+emoni5o
+em3pi
+e4mul
+em5ula
+emu3n
+e3my
+en5amo
+e4nant
+ench4er
+en3dic
+e5nea
+e5nee
+en3em
+en5ero
+en5esi
+en5est
+en3etr
+e3new
+en5ics
+e5nie
+e5nil
+e3nio
+en3ish
+en3it
+e5niu
+5eniz
+4enn
+4eno
+eno4g
+e4nos
+en3ov
+en4sw
+ent5age
+4enthes
+en3ua
+en5uf
+e3ny.
+4en3z
+e5of
+eo2g
+e4oi4
+e3ol
+eop3ar
+e1or
+eo3re
+eo5rol
+eos4
+e4ot
+eo4to
+e5out
+e5ow
+e2pa
+e3pai
+ep5anc
+e5pel
+e3pent
+ep5etitio
+ephe4
+e4pli
+e1po
+e4prec
+ep5reca
+e4pred
+ep3reh
+e3pro
+e4prob
+ep4sh
+ep5ti5b
+e4put
+ep5uta
+e1q
+equi3l
+e4q3ui3s
+er1a
+era4b
+4erand
+er3ar
+4erati.
+2erb
+er4bl
+er3ch
+er4che
+2ere.
+e3real
+ere5co
+ere3in
+er5el.
+er3emo
+er5ena
+er5ence
+4erene
+er3ent
+ere4q
+er5ess
+er3est
+eret4
+er1h
+er1i
+e1ria4
+5erick
+e3rien
+eri4er
+er3ine
+e1rio
+4erit
+er4iu
+eri4v
+e4riva
+er3m4
+er4nis
+4ernit
+5erniz
+er3no
+2ero
+er5ob
+e5roc
+ero4r
+er1ou
+er1s
+er3set
+ert3er
+4ertl
+er3tw
+4eru
+eru4t
+5erwau
+e1s4a
+e4sage.
+e4sages
+es2c
+e2sca
+es5can
+e3scr
+es5cu
+e1s2e
+e2sec
+es5ecr
+es5enc
+e4sert.
+e4serts
+e4serva
+4esh
+e3sha
+esh5en
+e1si
+e2sic
+e2sid
+es5iden
+es5igna
+e2s5im
+es4i4n
+esis4te
+esi4u
+e5skin
+es4mi
+e2sol
+es3olu
+e2son
+es5ona
+e1sp
+es3per
+es5pira
+es4pre
+2ess
+es4si4b
+estan4
+es3tig
+es5tim
+4es2to
+e3ston
+2estr
+e5stro
+estruc5
+e2sur
+es5urr
+es4w
+eta4b
+eten4d
+e3teo
+ethod3
+et1ic
+e5tide
+etin4
+eti4no
+e5tir
+e5titio
+et5itiv
+4etn
+et5ona
+e3tra
+e3tre
+et3ric
+et5rif
+et3rog
+et5ros
+et3ua
+et5ym
+et5z
+4eu
+e5un
+e3up
+eu3ro
+eus4
+eute4
+euti5l
+eu5tr
+eva2p5
+e2vas
+ev5ast
+e5vea
+ev3ell
+evel3o
+e5veng
+even4i
+ev1er
+e5verb
+e1vi
+ev3id
+evi4l
+e4vin
+evi4v
+e5voc
+e5vu
+e1wa
+e4wag
+e5wee
+e3wh
+ewil5
+ew3ing
+e3wit
+1exp
+5eyc
+5eye.
+eys4
+1fa
+fa3bl
+fab3r
+fa4ce
+4fag
+fain4
+fall5e
+4fa4ma
+fam5is
+5far
+far5th
+fa3ta
+fa3the
+4fato
+fault5
+4f5b
+4fd
+4fe.
+feas4
+feath3
+fe4b
+4feca
+5fect
+2fed
+fe3li
+fe4mo
+fen2d
+fend5e
+fer1
+5ferr
+fev4
+4f1f
+f4fes
+f4fie
+f5fin.
+f2f5is
+f4fly
+f2fy
+4fh
+1fi
+fi3a
+2f3ic.
+4f3ical
+f3ican
+4ficate
+f3icen
+fi3cer
+fic4i
+5ficia
+5ficie
+4fics
+fi3cu
+fi5del
+fight5
+fil5i
+fill5in
+4fily
+2fin
+5fina
+fin2d5
+fi2ne
+f1in3g
+fin4n
+fis4ti
+f4l2
+f5less
+flin4
+flo3re
+f2ly5
+4fm
+4fn
+1fo
+5fon
+fon4de
+fon4t
+fo2r
+fo5rat
+for5ay
+fore5t
+for4i
+fort5a
+fos5
+4f5p
+fra4t
+f5rea
+fres5c
+fri2
+fril4
+frol5
+2f3s
+2ft
+f4to
+f2ty
+3fu
+fu5el
+4fug
+fu4min
+fu5ne
+fu3ri
+fusi4
+fus4s
+4futa
+1fy
+1ga
+gaf4
+5gal.
+3gali
+ga3lo
+2gam
+ga5met
+g5amo
+gan5is
+ga3niz
+gani5za
+4gano
+gar5n4
+gass4
+gath3
+4gativ
+4gaz
+g3b
+gd4
+2ge.
+2ged
+geez4
+gel4in
+ge5lis
+ge5liz
+4gely
+1gen
+ge4nat
+ge5niz
+4geno
+4geny
+1geo
+ge3om
+g4ery
+5gesi
+geth5
+4geto
+ge4ty
+ge4v
+4g1g2
+g2ge
+g3ger
+gglu5
+ggo4
+gh3in
+gh5out
+gh4to
+5gi.
+1gi4a
+gia5r
+g1ic
+5gicia
+g4ico
+gien5
+5gies.
+gil4
+g3imen
+3g4in.
+gin5ge
+5g4ins
+5gio
+3gir
+gir4l
+g3isl
+gi4u
+5giv
+3giz
+gl2
+gla4
+glad5i
+5glas
+1gle
+gli4b
+g3lig
+3glo
+glo3r
+g1m
+g4my
+gn4a
+g4na.
+gnet4t
+g1ni
+g2nin
+g4nio
+g1no
+g4non
+1go
+3go.
+gob5
+5goe
+3g4o4g
+go3is
+gon2
+4g3o3na
+gondo5
+go3ni
+5goo
+go5riz
+gor5ou
+5gos.
+gov1
+g3p
+1gr
+4grada
+g4rai
+gran2
+5graph.
+g5rapher
+5graphic
+4graphy
+4gray
+gre4n
+4gress.
+4grit
+g4ro
+gruf4
+gs2
+g5ste
+gth3
+gu4a
+3guard
+2gue
+5gui5t
+3gun
+3gus
+4gu4t
+g3w
+1gy
+2g5y3n
+gy5ra
+h3ab4l
+hach4
+hae4m
+hae4t
+h5agu
+ha3la
+hala3m
+ha4m
+han4ci
+han4cy
+5hand.
+han4g
+hang5er
+hang5o
+h5a5niz
+han4k
+han4te
+hap3l
+hap5t
+ha3ran
+ha5ras
+har2d
+hard3e
+har4le
+harp5en
+har5ter
+has5s
+haun4
+5haz
+haz3a
+h1b
+1head
+3hear
+he4can
+h5ecat
+h4ed
+he5do5
+he3l4i
+hel4lis
+hel4ly
+h5elo
+hem4p
+he2n
+hena4
+hen5at
+heo5r
+hep5
+h4era
+hera3p
+her4ba
+here5a
+h3ern
+h5erou
+h3ery
+h1es
+he2s5p
+he4t
+het4ed
+heu4
+h1f
+h1h
+hi5an
+hi4co
+high5
+h4il2
+himer4
+h4ina
+hion4e
+hi4p
+hir4l
+hi3ro
+hir4p
+hir4r
+his3el
+his4s
+hith5er
+hi2v
+4hk
+4h1l4
+hlan4
+h2lo
+hlo3ri
+4h1m
+hmet4
+2h1n
+h5odiz
+h5ods
+ho4g
+hoge4
+hol5ar
+3hol4e
+ho4ma
+home3
+hon4a
+ho5ny
+3hood
+hoon4
+hor5at
+ho5ris
+hort3e
+ho5ru
+hos4e
+ho5sen
+hos1p
+1hous
+house3
+hov5el
+4h5p
+4hr4
+hree5
+hro5niz
+hro3po
+4h1s2
+h4sh
+h4tar
+ht1en
+ht5es
+h4ty
+hu4g
+hu4min
+hun5ke
+hun4t
+hus3t4
+hu4t
+h1w
+h4wart
+hy3pe
+hy3ph
+hy2s
+2i1a
+i2al
+iam4
+iam5ete
+i2an
+4ianc
+ian3i
+4ian4t
+ia5pe
+iass4
+i4ativ
+ia4tric
+i4atu
+ibe4
+ib3era
+ib5ert
+ib5ia
+ib3in
+ib5it.
+ib5ite
+i1bl
+ib3li
+i5bo
+i1br
+i2b5ri
+i5bun
+4icam
+5icap
+4icar
+i4car.
+i4cara
+icas5
+i4cay
+iccu4
+4iceo
+4ich
+2ici
+i5cid
+ic5ina
+i2cip
+ic3ipa
+i4cly
+i2c5oc
+4i1cr
+5icra
+i4cry
+ic4te
+ictu2
+ic4t3ua
+ic3ula
+ic4um
+ic5uo
+i3cur
+2id
+i4dai
+id5anc
+id5d
+ide3al
+ide4s
+i2di
+id5ian
+idi4ar
+i5die
+id3io
+idi5ou
+id1it
+id5iu
+i3dle
+i4dom
+id3ow
+i4dr
+i2du
+id5uo
+2ie4
+ied4e
+5ie5ga
+ield3
+ien5a4
+ien4e
+i5enn
+i3enti
+i1er.
+i3esc
+i1est
+i3et
+4if.
+if5ero
+iff5en
+if4fr
+4ific.
+i3fie
+i3fl
+4ift
+2ig
+iga5b
+ig3era
+ight3i
+4igi
+i3gib
+ig3il
+ig3in
+ig3it
+i4g4l
+i2go
+ig3or
+ig5ot
+i5gre
+igu5i
+ig1ur
+i3h
+4i5i4
+i3j
+4ik
+i1la
+il3a4b
+i4lade
+i2l5am
+ila5ra
+i3leg
+il1er
+ilev4
+il5f
+il1i
+il3ia
+il2ib
+il3io
+il4ist
+2ilit
+il2iz
+ill5ab
+4iln
+il3oq
+il4ty
+il5ur
+il3v
+i4mag
+im3age
+ima5ry
+imenta5r
+4imet
+im1i
+im5ida
+imi5le
+i5mini
+4imit
+im4ni
+i3mon
+i2mu
+im3ula
+2in.
+i4n3au
+4inav
+incel4
+in3cer
+4ind
+in5dling
+2ine
+i3nee
+iner4ar
+i5ness
+4inga
+4inge
+in5gen
+4ingi
+in5gling
+4ingo
+4ingu
+2ini
+i5ni.
+i4nia
+in3io
+in1is
+i5nite.
+5initio
+in3ity
+4ink
+4inl
+2inn
+2i1no
+i4no4c
+ino4s
+i4not
+2ins
+in3se
+insur5a
+2int.
+2in4th
+in1u
+i5nus
+4iny
+2io
+4io.
+ioge4
+io2gr
+i1ol
+io4m
+ion3at
+ion4ery
+ion3i
+io5ph
+ior3i
+i4os
+io5th
+i5oti
+io4to
+i4our
+2ip
+ipe4
+iphras4
+ip3i
+ip4ic
+ip4re4
+ip3ul
+i3qua
+iq5uef
+iq3uid
+iq3ui3t
+4ir
+i1ra
+ira4b
+i4rac
+ird5e
+ire4de
+i4ref
+i4rel4
+i4res
+ir5gi
+ir1i
+iri5de
+ir4is
+iri3tu
+5i5r2iz
+ir4min
+iro4g
+5iron.
+ir5ul
+2is.
+is5ag
+is3ar
+isas5
+2is1c
+is3ch
+4ise
+is3er
+3isf
+is5han
+is3hon
+ish5op
+is3ib
+isi4d
+i5sis
+is5itiv
+4is4k
+islan4
+4isms
+i2so
+iso5mer
+is1p
+is2pi
+is4py
+4is1s
+is4sal
+issen4
+is4ses
+is4ta.
+is1te
+is1ti
+ist4ly
+4istral
+i2su
+is5us
+4ita.
+ita4bi
+i4tag
+4ita5m
+i3tan
+i3tat
+2ite
+it3era
+i5teri
+it4es
+2ith
+i1ti
+4itia
+4i2tic
+it3ica
+5i5tick
+it3ig
+it5ill
+i2tim
+2itio
+4itis
+i4tism
+i2t5o5m
+4iton
+i4tram
+it5ry
+4itt
+it3uat
+i5tud
+it3ul
+4itz.
+i1u
+2iv
+iv3ell
+iv3en.
+i4v3er.
+i4vers.
+iv5il.
+iv5io
+iv1it
+i5vore
+iv3o3ro
+i4v3ot
+4i5w
+ix4o
+4iy
+4izar
+izi4
+5izont
+5ja
+jac4q
+ja4p
+1je
+jer5s
+4jestie
+4jesty
+jew3
+jo4p
+5judg
+3ka.
+k3ab
+k5ag
+kais4
+kal4
+k1b
+k2ed
+1kee
+ke4g
+ke5li
+k3en4d
+k1er
+kes4
+k3est.
+ke4ty
+k3f
+kh4
+k1i
+5ki.
+5k2ic
+k4ill
+kilo5
+k4im
+k4in.
+kin4de
+k5iness
+kin4g
+ki4p
+kis4
+k5ish
+kk4
+k1l
+4kley
+4kly
+k1m
+k5nes
+1k2no
+ko5r
+kosh4
+k3ou
+kro5n
+4k1s2
+k4sc
+ks4l
+k4sy
+k5t
+k1w
+lab3ic
+l4abo
+laci4
+l4ade
+la3dy
+lag4n
+lam3o
+3land
+lan4dl
+lan5et
+lan4te
+lar4g
+lar3i
+las4e
+la5tan
+4lateli
+4lativ
+4lav
+la4v4a
+2l1b
+lbin4
+4l1c2
+lce4
+l3ci
+2ld
+l2de
+ld4ere
+ld4eri
+ldi4
+ld5is
+l3dr
+l4dri
+le2a
+le4bi
+left5
+5leg.
+5legg
+le4mat
+lem5atic
+4len.
+3lenc
+5lene.
+1lent
+le3ph
+le4pr
+lera5b
+ler4e
+3lerg
+3l4eri
+l4ero
+les2
+le5sco
+5lesq
+3less
+5less.
+l3eva
+lev4er.
+lev4era
+lev4ers
+3ley
+4leye
+2lf
+l5fr
+4l1g4
+l5ga
+lgar3
+l4ges
+lgo3
+2l3h
+li4ag
+li2am
+liar5iz
+li4as
+li4ato
+li5bi
+5licio
+li4cor
+4lics
+4lict.
+l4icu
+l3icy
+l3ida
+lid5er
+3lidi
+lif3er
+l4iff
+li4fl
+5ligate
+3ligh
+li4gra
+3lik
+4l4i4l
+lim4bl
+lim3i
+li4mo
+l4im4p
+l4ina
+1l4ine
+lin3ea
+lin3i
+link5er
+li5og
+4l4iq
+lis4p
+l1it
+l2it.
+5litica
+l5i5tics
+liv3er
+l1iz
+4lj
+lka3
+l3kal
+lka4t
+l1l
+l4law
+l2le
+l5lea
+l3lec
+l3leg
+l3lel
+l3le4n
+l3le4t
+ll2i
+l2lin4
+l5lina
+ll4o
+lloqui5
+ll5out
+l5low
+2lm
+l5met
+lm3ing
+l4mod
+lmon4
+2l1n2
+3lo.
+lob5al
+lo4ci
+4lof
+3logic
+l5ogo
+3logu
+lom3er
+5long
+lon4i
+l3o3niz
+lood5
+5lope.
+lop3i
+l3opm
+lora4
+lo4rato
+lo5rie
+lor5ou
+5los.
+los5et
+5losophiz
+5losophy
+los4t
+lo4ta
+loun5d
+2lout
+4lov
+2lp
+lpa5b
+l3pha
+l5phi
+lp5ing
+l3pit
+l4pl
+l5pr
+4l1r
+2l1s2
+l4sc
+l2se
+l4sie
+4lt
+lt5ag
+ltane5
+l1te
+lten4
+ltera4
+lth3i
+l5ties.
+ltis4
+l1tr
+ltu2
+ltur3a
+lu5a
+lu3br
+luch4
+lu3ci
+lu3en
+luf4
+lu5id
+lu4ma
+5lumi
+l5umn.
+5lumnia
+lu3o
+luo3r
+4lup
+luss4
+lus3te
+1lut
+l5ven
+l5vet4
+2l1w
+1ly
+4lya
+4lyb
+ly5me
+ly3no
+2lys4
+l5yse
+1ma
+2mab
+ma2ca
+ma5chine
+ma4cl
+mag5in
+5magn
+2mah
+maid5
+4mald
+ma3lig
+ma5lin
+mal4li
+mal4ty
+5mania
+man5is
+man3iz
+4map
+ma5rine.
+ma5riz
+mar4ly
+mar3v
+ma5sce
+mas4e
+mas1t
+5mate
+math3
+ma3tis
+4matiza
+4m1b
+mba4t5
+m5bil
+m4b3ing
+mbi4v
+4m5c
+4me.
+2med
+4med.
+5media
+me3die
+m5e5dy
+me2g
+mel5on
+mel4t
+me2m
+mem1o3
+1men
+men4a
+men5ac
+men4de
+4mene
+men4i
+mens4
+mensu5
+3ment
+men4te
+me5on
+m5ersa
+2mes
+3mesti
+me4ta
+met3al
+me1te
+me5thi
+m4etr
+5metric
+me5trie
+me3try
+me4v
+4m1f
+2mh
+5mi.
+mi3a
+mid4a
+mid4g
+mig4
+3milia
+m5i5lie
+m4ill
+min4a
+3mind
+m5inee
+m4ingl
+min5gli
+m5ingly
+min4t
+m4inu
+miot4
+m2is
+mis4er.
+mis5l
+mis4ti
+m5istry
+4mith
+m2iz
+4mk
+4m1l
+m1m
+mma5ry
+4m1n
+mn4a
+m4nin
+mn4o
+1mo
+4mocr
+5mocratiz
+mo2d1
+mo4go
+mois2
+moi5se
+4mok
+mo5lest
+mo3me
+mon5et
+mon5ge
+moni3a
+mon4ism
+mon4ist
+mo3niz
+monol4
+mo3ny.
+mo2r
+4mora.
+mos2
+mo5sey
+mo3sp
+moth3
+m5ouf
+3mous
+mo2v
+4m1p
+mpara5
+mpa5rab
+mpar5i
+m3pet
+mphas4
+m2pi
+mpi4a
+mp5ies
+m4p1in
+m5pir
+mp5is
+mpo3ri
+mpos5ite
+m4pous
+mpov5
+mp4tr
+m2py
+4m3r
+4m1s2
+m4sh
+m5si
+4mt
+1mu
+mula5r4
+5mult
+multi3
+3mum
+mun2
+4mup
+mu4u
+4mw
+1na
+2n1a2b
+n4abu
+4nac.
+na4ca
+n5act
+nag5er.
+nak4
+na4li
+na5lia
+4nalt
+na5mit
+n2an
+nanci4
+nan4it
+nank4
+nar3c
+4nare
+nar3i
+nar4l
+n5arm
+n4as
+nas4c
+nas5ti
+n2at
+na3tal
+nato5miz
+n2au
+nau3se
+3naut
+nav4e
+4n1b4
+ncar5
+n4ces.
+n3cha
+n5cheo
+n5chil
+n3chis
+nc1in
+nc4it
+ncour5a
+n1cr
+n1cu
+n4dai
+n5dan
+n1de
+nd5est.
+ndi4b
+n5d2if
+n1dit
+n3diz
+n5duc
+ndu4r
+nd2we
+2ne.
+n3ear
+ne2b
+neb3u
+ne2c
+5neck
+2ned
+ne4gat
+neg5ativ
+5nege
+ne4la
+nel5iz
+ne5mi
+ne4mo
+1nen
+4nene
+3neo
+ne4po
+ne2q
+n1er
+nera5b
+n4erar
+n2ere
+n4er5i
+ner4r
+1nes
+2nes.
+4nesp
+2nest
+4nesw
+3netic
+ne4v
+n5eve
+ne4w
+n3f
+n4gab
+n3gel
+nge4n4e
+n5gere
+n3geri
+ng5ha
+n3gib
+ng1in
+n5git
+n4gla
+ngov4
+ng5sh
+n1gu
+n4gum
+n2gy
+4n1h4
+nha4
+nhab3
+nhe4
+3n4ia
+ni3an
+ni4ap
+ni3ba
+ni4bl
+ni4d
+ni5di
+ni4er
+ni2fi
+ni5ficat
+n5igr
+nik4
+n1im
+ni3miz
+n1in
+5nine.
+nin4g
+ni4o
+5nis.
+nis4ta
+n2it
+n4ith
+3nitio
+n3itor
+ni3tr
+n1j
+4nk2
+n5kero
+n3ket
+nk3in
+n1kl
+4n1l
+n5m
+nme4
+nmet4
+4n1n2
+nne4
+nni3al
+nni4v
+nob4l
+no3ble
+n5ocl
+4n3o2d
+3noe
+4nog
+noge4
+nois5i
+no5l4i
+5nologis
+3nomic
+n5o5miz
+no4mo
+no3my
+no4n
+non4ag
+non5i
+n5oniz
+4nop
+5nop5o5li
+nor5ab
+no4rary
+4nosc
+nos4e
+nos5t
+no5ta
+1nou
+3noun
+nov3el3
+nowl3
+n1p4
+npi4
+npre4c
+n1q
+n1r
+nru4
+2n1s2
+ns5ab
+nsati4
+ns4c
+n2se
+n4s3es
+nsid1
+nsig4
+n2sl
+ns3m
+n4soc
+ns4pe
+n5spi
+nsta5bl
+n1t
+nta4b
+nter3s
+nt2i
+n5tib
+nti4er
+nti2f
+n3tine
+n4t3ing
+nti4p
+ntrol5li
+nt4s
+ntu3me
+nu1a
+nu4d
+nu5en
+nuf4fe
+n3uin
+3nu3it
+n4um
+nu1me
+n5umi
+3nu4n
+n3uo
+nu3tr
+n1v2
+n1w4
+nym4
+nyp4
+4nz
+n3za
+4oa
+oad3
+o5a5les
+oard3
+oas4e
+oast5e
+oat5i
+ob3a3b
+o5bar
+obe4l
+o1bi
+o2bin
+ob5ing
+o3br
+ob3ul
+o1ce
+och4
+o3chet
+ocif3
+o4cil
+o4clam
+o4cod
+oc3rac
+oc5ratiz
+ocre3
+5ocrit
+octor5a
+oc3ula
+o5cure
+od5ded
+od3ic
+odi3o
+o2do4
+odor3
+od5uct.
+od5ucts
+o4el
+o5eng
+o3er
+oe4ta
+o3ev
+o2fi
+of5ite
+ofit4t
+o2g5a5r
+og5ativ
+o4gato
+o1ge
+o5gene
+o5geo
+o4ger
+o3gie
+1o1gis
+og3it
+o4gl
+o5g2ly
+3ogniz
+o4gro
+ogu5i
+1ogy
+2ogyn
+o1h2
+ohab5
+oi2
+oic3es
+oi3der
+oiff4
+oig4
+oi5let
+o3ing
+oint5er
+o5ism
+oi5son
+oist5en
+oi3ter
+o5j
+2ok
+o3ken
+ok5ie
+o1la
+o4lan
+olass4
+ol2d
+old1e
+ol3er
+o3lesc
+o3let
+ol4fi
+ol2i
+o3lia
+o3lice
+ol5id.
+o3li4f
+o5lil
+ol3ing
+o5lio
+o5lis.
+ol3ish
+o5lite
+o5litio
+o5liv
+olli4e
+ol5ogiz
+olo4r
+ol5pl
+ol2t
+ol3ub
+ol3ume
+ol3un
+o5lus
+ol2v
+o2ly
+om5ah
+oma5l
+om5atiz
+om2be
+om4bl
+o2me
+om3ena
+om5erse
+o4met
+om5etry
+o3mia
+om3ic.
+om3ica
+o5mid
+om1in
+o5mini
+5ommend
+omo4ge
+o4mon
+om3pi
+ompro5
+o2n
+on1a
+on4ac
+o3nan
+on1c
+3oncil
+2ond
+on5do
+o3nen
+on5est
+on4gu
+on1ic
+o3nio
+on1is
+o5niu
+on3key
+on4odi
+on3omy
+on3s
+onspi4
+onspir5a
+onsu4
+onten4
+on3t4i
+ontif5
+on5um
+onva5
+oo2
+ood5e
+ood5i
+oo4k
+oop3i
+o3ord
+oost5
+o2pa
+ope5d
+op1er
+3opera
+4operag
+2oph
+o5phan
+o5pher
+op3ing
+o3pit
+o5pon
+o4posi
+o1pr
+op1u
+opy5
+o1q
+o1ra
+o5ra.
+o4r3ag
+or5aliz
+or5ange
+ore5a
+o5real
+or3ei
+ore5sh
+or5est.
+orew4
+or4gu
+4o5ria
+or3ica
+o5ril
+or1in
+o1rio
+or3ity
+o3riu
+or2mi
+orn2e
+o5rof
+or3oug
+or5pe
+3orrh
+or4se
+ors5en
+orst4
+or3thi
+or3thy
+or4ty
+o5rum
+o1ry
+os3al
+os2c
+os4ce
+o3scop
+4oscopi
+o5scr
+os4i4e
+os5itiv
+os3ito
+os3ity
+osi4u
+os4l
+o2so
+os4pa
+os4po
+os2ta
+o5stati
+os5til
+os5tit
+o4tan
+otele4g
+ot3er.
+ot5ers
+o4tes
+4oth
+oth5esi
+oth3i4
+ot3ic.
+ot5ica
+o3tice
+o3tif
+o3tis
+oto5s
+ou2
+ou3bl
+ouch5i
+ou5et
+ou4l
+ounc5er
+oun2d
+ou5v
+ov4en
+over4ne
+over3s
+ov4ert
+o3vis
+oviti4
+o5v4ol
+ow3der
+ow3el
+ow5est
+ow1i
+own5i
+o4wo
+oy1a
+1pa
+pa4ca
+pa4ce
+pac4t
+p4ad
+5pagan
+p3agat
+p4ai
+pain4
+p4al
+pan4a
+pan3el
+pan4ty
+pa3ny
+pa1p
+pa4pu
+para5bl
+par5age
+par5di
+3pare
+par5el
+p4a4ri
+par4is
+pa2te
+pa5ter
+5pathic
+pa5thy
+pa4tric
+pav4
+3pay
+4p1b
+pd4
+4pe.
+3pe4a
+pear4l
+pe2c
+2p2ed
+3pede
+3pedi
+pedia4
+ped4ic
+p4ee
+pee4d
+pek4
+pe4la
+peli4e
+pe4nan
+p4enc
+pen4th
+pe5on
+p4era.
+pera5bl
+p4erag
+p4eri
+peri5st
+per4mal
+perme5
+p4ern
+per3o
+per3ti
+pe5ru
+per1v
+pe2t
+pe5ten
+pe5tiz
+4pf
+4pg
+4ph.
+phar5i
+phe3no
+ph4er
+ph4es.
+ph1ic
+5phie
+ph5ing
+5phisti
+3phiz
+ph2l
+3phob
+3phone
+5phoni
+pho4r
+4phs
+ph3t
+5phu
+1phy
+pi3a
+pian4
+pi4cie
+pi4cy
+p4id
+p5ida
+pi3de
+5pidi
+3piec
+pi3en
+pi4grap
+pi3lo
+pi2n
+p4in.
+pind4
+p4ino
+3pi1o
+pion4
+p3ith
+pi5tha
+pi2tu
+2p3k2
+1p2l2
+3plan
+plas5t
+pli3a
+pli5er
+4plig
+pli4n
+ploi4
+plu4m
+plum4b
+4p1m
+2p3n
+po4c
+5pod.
+po5em
+po3et5
+5po4g
+poin2
+5point
+poly5t
+po4ni
+po4p
+1p4or
+po4ry
+1pos
+pos1s
+p4ot
+po4ta
+5poun
+4p1p
+ppa5ra
+p2pe
+p4ped
+p5pel
+p3pen
+p3per
+p3pet
+ppo5site
+pr2
+pray4e
+5preci
+pre5co
+pre3em
+pref5ac
+pre4la
+pre3r
+p3rese
+3press
+pre5ten
+pre3v
+5pri4e
+prin4t3
+pri4s
+pris3o
+p3roca
+prof5it
+pro3l
+pros3e
+pro1t
+2p1s2
+p2se
+ps4h
+p4sib
+2p1t
+pt5a4b
+p2te
+p2th
+pti3m
+ptu4r
+p4tw
+pub3
+pue4
+puf4
+pul3c
+pu4m
+pu2n
+pur4r
+5pus
+pu2t
+5pute
+put3er
+pu3tr
+put4ted
+put4tin
+p3w
+qu2
+qua5v
+2que.
+3quer
+3quet
+2rab
+ra3bi
+rach4e
+r5acl
+raf5fi
+raf4t
+r2ai
+ra4lo
+ram3et
+r2ami
+rane5o
+ran4ge
+r4ani
+ra5no
+rap3er
+3raphy
+rar5c
+rare4
+rar5ef
+4raril
+r2as
+ration4
+rau4t
+ra5vai
+rav3el
+ra5zie
+r1b
+r4bab
+r4bag
+rbi2
+rbi4f
+r2bin
+r5bine
+rb5ing.
+rb4o
+r1c
+r2ce
+rcen4
+r3cha
+rch4er
+r4ci4b
+rc4it
+rcum3
+r4dal
+rd2i
+rdi4a
+rdi4er
+rdin4
+rd3ing
+2re.
+re1al
+re3an
+re5arr
+5reav
+re4aw
+r5ebrat
+rec5oll
+rec5ompe
+re4cre
+2r2ed
+re1de
+re3dis
+red5it
+re4fac
+re2fe
+re5fer.
+re3fi
+re4fy
+reg3is
+re5it
+re1li
+re5lu
+r4en4ta
+ren4te
+re1o
+re5pin
+re4posi
+re1pu
+r1er4
+r4eri
+rero4
+re5ru
+r4es.
+re4spi
+ress5ib
+res2t
+re5stal
+re3str
+re4ter
+re4ti4z
+re3tri
+reu2
+re5uti
+rev2
+re4val
+rev3el
+r5ev5er.
+re5vers
+re5vert
+re5vil
+rev5olu
+re4wh
+r1f
+rfu4
+r4fy
+rg2
+rg3er
+r3get
+r3gic
+rgi4n
+rg3ing
+r5gis
+r5git
+r1gl
+rgo4n
+r3gu
+rh4
+4rh.
+4rhal
+ri3a
+ria4b
+ri4ag
+r4ib
+rib3a
+ric5as
+r4ice
+4rici
+5ricid
+ri4cie
+r4ico
+rid5er
+ri3enc
+ri3ent
+ri1er
+ri5et
+rig5an
+5rigi
+ril3iz
+5riman
+rim5i
+3rimo
+rim4pe
+r2ina
+5rina.
+rin4d
+rin4e
+rin4g
+ri1o
+5riph
+riph5e
+ri2pl
+rip5lic
+r4iq
+r2is
+r4is.
+ris4c
+r3ish
+ris4p
+ri3ta3b
+r5ited.
+rit5er.
+rit5ers
+rit3ic
+ri2tu
+rit5ur
+riv5el
+riv3et
+riv3i
+r3j
+r3ket
+rk4le
+rk4lin
+r1l
+rle4
+r2led
+r4lig
+r4lis
+rl5ish
+r3lo4
+r1m
+rma5c
+r2me
+r3men
+rm5ers
+rm3ing
+r4ming.
+r4mio
+r3mit
+r4my
+r4nar
+r3nel
+r4ner
+r5net
+r3ney
+r5nic
+r1nis4
+r3nit
+r3niv
+rno4
+r4nou
+r3nu
+rob3l
+r2oc
+ro3cr
+ro4e
+ro1fe
+ro5fil
+rok2
+ro5ker
+5role.
+rom5ete
+rom4i
+rom4p
+ron4al
+ron4e
+ro5n4is
+ron4ta
+1room
+5root
+ro3pel
+rop3ic
+ror3i
+ro5ro
+ros5per
+ros4s
+ro4the
+ro4ty
+ro4va
+rov5el
+rox5
+r1p
+r4pea
+r5pent
+rp5er.
+r3pet
+rp4h4
+rp3ing
+r3po
+r1r4
+rre4c
+rre4f
+r4reo
+rre4st
+rri4o
+rri4v
+rron4
+rros4
+rrys4
+4rs2
+r1sa
+rsa5ti
+rs4c
+r2se
+r3sec
+rse4cr
+rs5er.
+rs3es
+rse5v2
+r1sh
+r5sha
+r1si
+r4si4b
+rson3
+r1sp
+r5sw
+rtach4
+r4tag
+r3teb
+rten4d
+rte5o
+r1ti
+rt5ib
+rti4d
+r4tier
+r3tig
+rtil3i
+rtil4l
+r4tily
+r4tist
+r4tiv
+r3tri
+rtroph4
+rt4sh
+ru3a
+ru3e4l
+ru3en
+ru4gl
+ru3in
+rum3pl
+ru2n
+runk5
+run4ty
+r5usc
+ruti5n
+rv4e
+rvel4i
+r3ven
+rv5er.
+r5vest
+r3vey
+r3vic
+rvi4v
+r3vo
+r1w
+ry4c
+5rynge
+ry3t
+sa2
+2s1ab
+5sack
+sac3ri
+s3act
+5sai
+salar4
+sal4m
+sa5lo
+sal4t
+3sanc
+san4de
+s1ap
+sa5ta
+5sa3tio
+sat3u
+sau4
+sa5vor
+5saw
+4s5b
+scan4t5
+sca4p
+scav5
+s4ced
+4scei
+s4ces
+sch2
+s4cho
+3s4cie
+5scin4d
+scle5
+s4cli
+scof4
+4scopy
+scour5a
+s1cu
+4s5d
+4se.
+se4a
+seas4
+sea5w
+se2c3o
+3sect
+4s4ed
+se4d4e
+s5edl
+se2g
+seg3r
+5sei
+se1le
+5self
+5selv
+4seme
+se4mol
+sen5at
+4senc
+sen4d
+s5ened
+sen5g
+s5enin
+4sentd
+4sentl
+sep3a3
+4s1er.
+s4erl
+ser4o
+4servo
+s1e4s
+se5sh
+ses5t
+5se5um
+5sev
+sev3en
+sew4i
+5sex
+4s3f
+2s3g
+s2h
+2sh.
+sh1er
+5shev
+sh1in
+sh3io
+3ship
+shiv5
+sho4
+sh5old
+shon3
+shor4
+short5
+4shw
+si1b
+s5icc
+3side.
+5sides
+5sidi
+si5diz
+4signa
+sil4e
+4sily
+2s1in
+s2ina
+5sine.
+s3ing
+1sio
+5sion
+sion5a
+si2r
+sir5a
+1sis
+3sitio
+5siu
+1siv
+5siz
+sk2
+4ske
+s3ket
+sk5ine
+sk5ing
+s1l2
+s3lat
+s2le
+slith5
+2s1m
+s3ma
+small3
+sman3
+smel4
+s5men
+5smith
+smol5d4
+s1n4
+1so
+so4ce
+soft3
+so4lab
+sol3d2
+so3lic
+5solv
+3som
+3s4on.
+sona4
+son4g
+s4op
+5sophic
+s5ophiz
+s5ophy
+sor5c
+sor5d
+4sov
+so5vi
+2spa
+5spai
+spa4n
+spen4d
+2s5peo
+2sper
+s2phe
+3spher
+spho5
+spil4
+sp5ing
+4spio
+s4ply
+s4pon
+spor4
+4spot
+squal4l
+s1r
+2ss
+s1sa
+ssas3
+s2s5c
+s3sel
+s5seng
+s4ses.
+s5set
+s1si
+s4sie
+ssi4er
+ss5ily
+s4sl
+ss4li
+s4sn
+sspend4
+ss2t
+ssur5a
+ss5w
+2st.
+s2tag
+s2tal
+stam4i
+5stand
+s4ta4p
+5stat.
+s4ted
+stern5i
+s5tero
+ste2w
+stew5a
+s3the
+st2i
+s4ti.
+s5tia
+s1tic
+5stick
+s4tie
+s3tif
+st3ing
+5stir
+s1tle
+5stock
+stom3a
+5stone
+s4top
+3store
+st4r
+s4trad
+5stratu
+s4tray
+s4trid
+4stry
+4st3w
+s2ty
+1su
+su1al
+su4b3
+su2g3
+su5is
+suit3
+s4ul
+su2m
+sum3i
+su2n
+su2r
+4sv
+sw2
+4swo
+s4y
+4syc
+3syl
+syn5o
+sy5rin
+1ta
+3ta.
+2tab
+ta5bles
+5taboliz
+4taci
+ta5do
+4taf4
+tai5lo
+ta2l
+ta5la
+tal5en
+tal3i
+4talk
+tal4lis
+ta5log
+ta5mo
+tan4de
+tanta3
+ta5per
+ta5pl
+tar4a
+4tarc
+4tare
+ta3riz
+tas4e
+ta5sy
+4tatic
+ta4tur
+taun4
+tav4
+2taw
+tax4is
+2t1b
+4tc
+t4ch
+tch5et
+4t1d
+4te.
+tead4i
+4teat
+tece4
+5tect
+2t1ed
+te5di
+1tee
+teg4
+te5ger
+te5gi
+3tel.
+teli4
+5tels
+te2ma2
+tem3at
+3tenan
+3tenc
+3tend
+4tenes
+1tent
+ten4tag
+1teo
+te4p
+te5pe
+ter3c
+5ter3d
+1teri
+ter5ies
+ter3is
+teri5za
+5ternit
+ter5v
+4tes.
+4tess
+t3ess.
+teth5e
+3teu
+3tex
+4tey
+2t1f
+4t1g
+2th.
+than4
+th2e
+4thea
+th3eas
+the5at
+the3is
+3thet
+th5ic.
+th5ica
+4thil
+5think
+4thl
+th5ode
+5thodic
+4thoo
+thor5it
+tho5riz
+2ths
+1tia
+ti4ab
+ti4ato
+2ti2b
+4tick
+t4ico
+t4ic1u
+5tidi
+3tien
+tif2
+ti5fy
+2tig
+5tigu
+till5in
+1tim
+4timp
+tim5ul
+2t1in
+t2ina
+3tine.
+3tini
+1tio
+ti5oc
+tion5ee
+5tiq
+ti3sa
+3tise
+tis4m
+ti5so
+tis4p
+5tistica
+ti3tl
+ti4u
+1tiv
+tiv4a
+1tiz
+ti3za
+ti3zen
+2tl
+t5la
+tlan4
+3tle.
+3tled
+3tles.
+t5let.
+t5lo
+4t1m
+tme4
+2t1n2
+1to
+to3b
+to5crat
+4todo
+2tof
+to2gr
+to5ic
+to2ma
+tom4b
+to3my
+ton4ali
+to3nat
+4tono
+4tony
+to2ra
+to3rie
+tor5iz
+tos2
+5tour
+4tout
+to3war
+4t1p
+1tra
+tra3b
+tra5ch
+traci4
+trac4it
+trac4te
+tras4
+tra5ven
+trav5es5
+tre5f
+tre4m
+trem5i
+5tria
+tri5ces
+5tricia
+4trics
+2trim
+tri4v
+tro5mi
+tron5i
+4trony
+tro5phe
+tro3sp
+tro3v
+tru5i
+trus4
+4t1s2
+t4sc
+tsh4
+t4sw
+4t3t2
+t4tes
+t5to
+ttu4
+1tu
+tu1a
+tu3ar
+tu4bi
+tud2
+4tue
+4tuf4
+5tu3i
+3tum
+tu4nis
+2t3up.
+3ture
+5turi
+tur3is
+tur5o
+tu5ry
+3tus
+4tv
+tw4
+4t1wa
+twis4
+4two
+1ty
+4tya
+2tyl
+type3
+ty5ph
+4tz
+tz4e
+4uab
+uac4
+ua5na
+uan4i
+uar5ant
+uar2d
+uar3i
+uar3t
+u1at
+uav4
+ub4e
+u4bel
+u3ber
+u4bero
+u1b4i
+u4b5ing
+u3ble.
+u3ca
+uci4b
+uc4it
+ucle3
+u3cr
+u3cu
+u4cy
+ud5d
+ud3er
+ud5est
+udev4
+u1dic
+ud3ied
+ud3ies
+ud5is
+u5dit
+u4don
+ud4si
+u4du
+u4ene
+uens4
+uen4te
+uer4il
+3ufa
+u3fl
+ugh3en
+ug5in
+2ui2
+uil5iz
+ui4n
+u1ing
+uir4m
+uita4
+uiv3
+uiv4er.
+u5j
+4uk
+u1la
+ula5b
+u5lati
+ulch4
+5ulche
+ul3der
+ul4e
+u1len
+ul4gi
+ul2i
+u5lia
+ul3ing
+ul5ish
+ul4lar
+ul4li4b
+ul4lis
+4ul3m
+u1l4o
+4uls
+uls5es
+ul1ti
+ultra3
+4ultu
+u3lu
+ul5ul
+ul5v
+um5ab
+um4bi
+um4bly
+u1mi
+u4m3ing
+umor5o
+um2p
+unat4
+u2ne
+un4er
+u1ni
+un4im
+u2nin
+un5ish
+uni3v
+un3s4
+un4sw
+unt3ab
+un4ter.
+un4tes
+unu4
+un5y
+un5z
+u4ors
+u5os
+u1ou
+u1pe
+uper5s
+u5pia
+up3ing
+u3pl
+up3p
+upport5
+upt5ib
+uptu4
+u1ra
+4ura.
+u4rag
+u4ras
+ur4be
+urc4
+ur1d
+ure5at
+ur4fer
+ur4fr
+u3rif
+uri4fic
+ur1in
+u3rio
+u1rit
+ur3iz
+ur2l
+url5ing.
+ur4no
+uros4
+ur4pe
+ur4pi
+urs5er
+ur5tes
+ur3the
+urti4
+ur4tie
+u3ru
+2us
+u5sad
+u5san
+us4ap
+usc2
+us3ci
+use5a
+u5sia
+u3sic
+us4lin
+us1p
+us5sl
+us5tere
+us1tr
+u2su
+usur4
+uta4b
+u3tat
+4ute.
+4utel
+4uten
+uten4i
+4u1t2i
+uti5liz
+u3tine
+ut3ing
+ution5a
+u4tis
+5u5tiz
+u4t1l
+ut5of
+uto5g
+uto5matic
+u5ton
+u4tou
+uts4
+u3u
+uu4m
+u1v2
+uxu3
+uz4e
+1va
+5va.
+2v1a4b
+vac5il
+vac3u
+vag4
+va4ge
+va5lie
+val5o
+val1u
+va5mo
+va5niz
+va5pi
+var5ied
+3vat
+4ve.
+4ved
+veg3
+v3el.
+vel3li
+ve4lo
+v4ely
+ven3om
+v5enue
+v4erd
+5vere.
+v4erel
+v3eren
+ver5enc
+v4eres
+ver3ie
+vermi4n
+3verse
+ver3th
+v4e2s
+4ves.
+ves4te
+ve4te
+vet3er
+ve4ty
+vi5ali
+5vian
+5vide.
+5vided
+4v3iden
+5vides
+5vidi
+v3if
+vi5gn
+vik4
+2vil
+5vilit
+v3i3liz
+v1in
+4vi4na
+v2inc
+vin5d
+4ving
+vio3l
+v3io4r
+vi1ou
+vi4p
+vi5ro
+vis3it
+vi3so
+vi3su
+4viti
+vit3r
+4vity
+3viv
+5vo.
+voi4
+3vok
+vo4la
+v5ole
+5volt
+3volv
+vom5i
+vor5ab
+vori4
+vo4ry
+vo4ta
+4votee
+4vv4
+v4y
+w5abl
+2wac
+wa5ger
+wag5o
+wait5
+w5al.
+wam4
+war4t
+was4t
+wa1te
+wa5ver
+w1b
+wea5rie
+weath3
+wed4n
+weet3
+wee5v
+wel4l
+w1er
+west3
+w3ev
+whi4
+wi2
+wil2
+will5in
+win4de
+win4g
+wir4
+3wise
+with3
+wiz5
+w4k
+wl4es
+wl3in
+w4no
+1wo2
+wom1
+wo5ven
+w5p
+wra4
+wri4
+writa4
+w3sh
+ws4l
+ws4pe
+w5s4t
+4wt
+wy4
+x1a
+xac5e
+x4ago
+xam3
+x4ap
+xas5
+x3c2
+x1e
+xe4cuto
+x2ed
+xer4i
+xe5ro
+x1h
+xhi2
+xhil5
+xhu4
+x3i
+xi5a
+xi5c
+xi5di
+x4ime
+xi5miz
+x3o
+x4ob
+x3p
+xpan4d
+xpecto5
+xpe3d
+x1t2
+x3ti
+x1u
+xu3a
+xx4
+y5ac
+3yar4
+y5at
+y1b
+y1c
+y2ce
+yc5er
+y3ch
+ych4e
+ycom4
+ycot4
+y1d
+y5ee
+y1er
+y4erf
+yes4
+ye4t
+y5gi
+4y3h
+y1i
+y3la
+ylla5bl
+y3lo
+y5lu
+ymbol5
+yme4
+ympa3
+yn3chr
+yn5d
+yn5g
+yn5ic
+5ynx
+y1o4
+yo5d
+y4o5g
+yom4
+yo5net
+y4ons
+y4os
+y4ped
+yper5
+yp3i
+y3po
+y4poc
+yp2ta
+y5pu
+yra5m
+yr5ia
+y3ro
+yr4r
+ys4c
+y3s2e
+ys3ica
+ys3io
+3ysis
+y4so
+yss4
+ys1t
+ys3ta
+ysur4
+y3thin
+yt3ic
+y1w
+za1
+z5a2b
+zar2
+4zb
+2ze
+ze4n
+ze4p
+z1er
+ze3ro
+zet4
+2z1i
+z4il
+z4is
+5zl
+4zm
+1zo
+zo4m
+zo5ol
+zte4
+4z1z2
+z4zy
+.con5gr
+.de5riva
+.dri5v4
+.eth1y6l1
+.eu4ler
+.ev2
+.ever5si5b
+.ga4s1om1
+.ge4ome
+.ge5ot1
+.he3mo1
+.he3p6a
+.he3roe
+.in5u2t
+.kil2n3i
+.ko6r1te1
+.le6ices
+.me4ga1l
+.met4ala
+.mim5i2c1
+.mi1s4ers
+.ne6o3f
+.noe1th
+.non1e2m
+.poly1s
+.post1am
+.pre1am
+.rav5en1o
+.semi5
+.sem4ic
+.semid6
+.semip4
+.semir4
+.sem6is4
+.semiv4
+.sph6in1
+.spin1o
+.ta5pes1tr
+.te3legr
+.to6pog
+.to2q
+.un3at5t
+.un5err5
+.vi2c3ar
+.we2b1l
+.re1e4c
+a5bolic
+a2cabl
+af6fish
+am1en3ta5b
+anal6ys
+ano5a2c
+ans5gr
+ans3v
+anti1d
+an3ti1n2
+anti1re
+a4pe5able
+ar3che5t
+ar2range
+as5ymptot
+ath3er1o1s
+at6tes.
+augh4tl
+au5li5f
+av3iou
+back2er.
+ba6r1onie
+ba1thy
+bbi4t
+be2vie
+bi5d2if
+bil2lab
+bio5m
+bi1orb
+bio1rh
+b1i3tive
+blan2d1
+blin2d1
+blon2d2
+bor1no5
+bo2t1u1l
+brus4q
+bus6i2er
+bus6i2es
+buss4ing
+but2ed.
+but4ted
+cad5e1m
+cat1a1s2
+4chs.
+chs3hu
+chie5vo
+cig3a3r
+cin2q
+cle4ar
+co6ph1o3n
+cous2ti
+cri3tie
+croc1o1d
+cro5e2co
+c2tro3me6c
+1cu2r1ance
+2d3alone
+data1b
+dd5a5b
+d2d5ib
+de4als.
+de5clar1
+de2c5lina
+de3fin3iti
+de2mos
+des3ic
+de2tic
+dic1aid
+dif5fra
+3di1methy
+di2ren
+di2rer
+2d1lead
+2d1li2e
+3do5word
+dren1a5l
+drif2t1a
+d1ri3pleg5
+drom3e5d
+d3tab
+du2al.
+du1op1o1l
+ea4n3ies
+e3chas
+edg1l
+ed1uling
+eli2t1is
+e1loa
+en1dix
+eo3grap
+1e6p3i3neph1
+e2r3i4an.
+e3spac6i
+eth1y6l1ene
+5eu2clid1
+feb1rua
+fermi1o
+3fich
+fit5ted.
+fla1g6el
+flow2er.
+3fluor
+gen2cy.
+ge3o1d
+ght1we
+g1lead
+get2ic.
+4g1lish
+5glo5bin
+1g2nac
+gnet1ism
+gno5mo
+g2n1or.
+g2noresp
+2g1o4n3i1za
+graph5er.
+griev1
+g1utan
+hair1s
+ha2p3ar5r
+hatch1
+hex2a3
+hite3sid
+h3i5pel1a4
+hnau3z
+ho6r1ic.
+h2t1eou
+hypo1tha
+id4ios
+ifac1et
+ign4it
+ignit1er
+i4jk
+im3ped3a
+infra1s2
+i5nitely.
+irre6v3oc
+i1tesima
+ith5i2l
+itin5er5ar
+janu3a
+japan1e2s
+je1re1m
+1ke6ling
+1ki5netic
+1kovian
+k3sha
+la4c3i5e
+lai6n3ess
+lar5ce1n
+l3chai
+l3chil6d1
+lead6er.
+lea4s1a
+1lec3ta6b
+le3g6en2dre
+1le1noid
+lith1o5g
+ll1fl
+l2l3ish
+l5mo3nell
+lo1bot1o1
+lo2ges.
+load4ed.
+load6er.
+l3tea
+lth5i2ly
+lue1p
+1lunk3er
+1lum5bia.
+3lyg1a1mi
+ly5styr
+ma1la1p
+m2an.
+man3u1sc
+mar1gin1
+medi2c
+med3i3cin
+medio6c1
+me3gran3
+m2en.
+3mi3da5b
+3milita
+mil2l1ag
+mil5li5li
+mi6n3is.
+mi1n2ut1er
+mi1n2ut1est
+m3ma1b
+5maph1ro1
+5moc1ra1t
+mo5e2las
+mol1e5c
+mon4ey1l
+mono3ch
+mo4no1en
+moro6n5is
+mono1s6
+moth4et2
+m1ou3sin
+m5shack2
+mu2dro
+mul2ti5u
+n3ar4chs.
+n3ch2es1t
+ne3back
+2ne1ski
+n1dieck
+nd3thr
+nfi6n3ites
+4n5i4an.
+nge5nes
+ng1ho
+ng1spr
+nk3rup
+n5less
+5noc3er1os
+nom1a6l
+nom5e1no
+n1o1mist
+non1eq
+non1i4so
+5nop1oly.
+no1vemb
+ns5ceiv
+ns4moo
+ntre1p
+obli2g1
+o3chas
+odel3li
+odit1ic
+oerst2
+oke1st
+o3les3ter
+oli3gop1o1
+o1lo3n4om
+o3mecha6
+onom1ic
+o3norma
+o3no2t1o3n
+o3nou
+op1ism.
+or4tho3ni4t
+orth1ri
+or5tively
+o4s3pher
+o5test1er
+o5tes3tor
+oth3e1o1s
+ou3ba3do
+o6v3i4an.
+oxi6d1ic
+pal6mat
+parag6ra4
+par4a1le
+param4
+para3me
+pee2v1
+phi2l3ant
+phi5lat1e3l
+pi2c1a3d
+pli2c1ab
+pli5nar
+poin3ca
+1pole.
+poly1e
+po3lyph1ono
+1prema3c
+pre1neu
+pres2pli
+pro2cess
+proc3i3ty.
+pro2g1e
+3pseu2d
+pseu3d6o3d2
+pseu3d6o3f2
+pto3mat4
+p5trol3
+pu5bes5c
+quain2t1e
+qu6a3si3
+quasir6
+quasis6
+quin5tes5s
+qui3v4ar
+r1abolic
+3rab1o1loi
+ra3chu
+r3a3dig
+radi1o6g
+r2amen
+3ra4m5e1triz
+ra3mou
+ra5n2has
+ra1or
+r3bin1ge
+re2c3i1pr
+rec5t6ang
+re4t1ribu
+r3ial.
+riv1o1l
+6rk.
+rk1ho
+r1krau
+6rks.
+r5le5qu
+ro1bot1
+ro5e2las
+ro5epide1
+ro3mesh
+ro1tron
+r3pau5li
+rse1rad1i
+r1thou
+r1treu
+r1veil
+rz1sc
+sales3c
+sales5w
+5sa3par5il
+sca6p1er
+sca2t1ol
+s4chitz
+schro1ding1
+1sci2utt
+scrap4er.
+scy4th1
+sem1a1ph
+se3mes1t
+se1mi6t5ic
+sep3temb
+shoe1st
+sid2ed.
+side5st
+side5sw
+si5resid
+sky1sc
+3slova1kia
+3s2og1a1my
+so2lute
+3s2pace
+1s2pacin
+spe3cio
+spher1o
+spi2c1il
+spokes5w
+sports3c
+sports3w
+s3qui3to
+s2s1a3chu1
+ss3hat
+s2s3i4an.
+s5sign5a3b
+1s2tamp
+s2t1ant5shi
+star3tli
+sta1ti
+st5b
+1stor1ab
+strat1a1g
+strib5ut
+st5scr
+stu1pi4d1
+styl1is
+su2per1e6
+1sync
+1syth3i2
+swimm6
+5tab1o1lism
+ta3gon.
+talk1a5
+t1a1min
+t6ap6ath
+5tar2rh
+tch1c
+tch3i1er
+t1cr
+teach4er.
+tele2g
+tele1r6o
+3ter1gei
+ter2ic.
+t3ess2es
+tha4l1am
+tho3don
+th1o5gen1i
+tho1k2er
+thy4l1an
+thy3sc
+2t3i4an.
+ti2n3o1m
+t1li2er
+tolo2gy
+tot3ic
+trai3tor1
+tra1vers
+travers3a3b
+treach1e
+tr4ial.
+3tro1le1um
+trof4ic.
+tro3fit
+tro1p2is
+3trop1o5les
+3trop1o5lis
+t1ro1pol3it
+tsch3ie
+ttrib1ut1
+turn3ar
+t1wh
+ty2p5al
+ua3drati
+uad1ratu
+u5do3ny
+uea1m
+u2r1al.
+uri4al.
+us2er.
+v1ativ
+v1oir5du1
+va6guer
+vaude3v
+1verely.
+v1er1eig
+ves1tite
+vi1vip3a3r
+voice1p
+waste3w6a2
+wave1g4
+w3c
+week1n
+wide5sp
+wo4k1en
+wrap3aro
+writ6er.
+x1q
+xquis3
+y5che3d
+ym5e5try
+y1stro
+yes5ter1y
+z3ian.
+z3o1phr
+z2z3w
+`
+
+var (
+ defaultHyphenator *Hyphenator
+ defaultHyphenatorOnce sync.Once
+)
+
+// initDefaultHyphenator lazily initializes the default US English hyphenator.
+func initDefaultHyphenator() {
+ defaultHyphenatorOnce.Do(func() {
+ lines := splitPatternLines(enUSPatterns)
+ defaultHyphenator = NewHyphenator(lines)
+ })
+}
+
+// splitPatternLines splits the raw pattern string into individual patterns.
+func splitPatternLines(s string) []string {
+ var result []string
+ start := 0
+ for i := 0; i < len(s); i++ {
+ if s[i] == '\n' {
+ line := s[start:i]
+ if len(line) > 0 {
+ result = append(result, line)
+ }
+ start = i + 1
+ }
+ }
+ if start < len(s) {
+ line := s[start:]
+ if len(line) > 0 {
+ result = append(result, line)
+ }
+ }
+ return result
+}
diff --git a/internal/providers/paper/hyphenation_test.go b/internal/providers/paper/hyphenation_test.go
new file mode 100644
index 00000000..383f837c
--- /dev/null
+++ b/internal/providers/paper/hyphenation_test.go
@@ -0,0 +1,46 @@
+package paper
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+)
+
+func TestNewHyphenator_HyphenatesKnownWord(t *testing.T) {
+ t.Parallel()
+
+ h := NewHyphenator([]string{"hy3ph", "he2n", "hena4", "hen5at"})
+
+ breaks := h.Hyphenate("hyphenation")
+ assert.NotEmpty(t, breaks)
+ for _, b := range breaks {
+ assert.True(t, b > 0 && b < len("hyphenation"))
+ }
+}
+
+func TestHyphenate_ShortWordsAndNonAlpha(t *testing.T) {
+ t.Parallel()
+
+ h := defaultRichTextHyphenator()
+ require.NotNil(t, h)
+
+ assert.Nil(t, h.Hyphenate("cat"), "words under 4 runes never hyphenate")
+ assert.NotEmpty(t, h.Hyphenate("hyphenation"))
+}
+
+func TestIsAlphaHyphenationWord(t *testing.T) {
+ t.Parallel()
+
+ assert.True(t, isAlphaHyphenationWord("hyphenation"))
+ assert.False(t, isAlphaHyphenationWord("abc123"))
+ assert.False(t, isAlphaHyphenationWord("with-dash"))
+}
+
+func TestParseHyphenationPattern(t *testing.T) {
+ t.Parallel()
+
+ letters, values := parseHyphenationPattern("hy3ph")
+ assert.Equal(t, "hyph", letters)
+ assert.Equal(t, []int{0, 0, 3, 0, 0}, values)
+}
diff --git a/internal/providers/paper/image.go b/internal/providers/paper/image.go
index 1c6c7264..8e58742f 100644
--- a/internal/providers/paper/image.go
+++ b/internal/providers/paper/image.go
@@ -5,13 +5,12 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
- "fmt"
"math"
"strconv"
"strings"
+ "github.com/avdoseferovic/paper/internal/imagecodec"
gofpdf "github.com/avdoseferovic/paper/internal/pdf"
- svgraster "github.com/avdoseferovic/paper/internal/svg"
"github.com/avdoseferovic/paper/pkg/consts/extension"
"github.com/avdoseferovic/paper/pkg/core"
@@ -120,18 +119,19 @@ func (s *Image) registerImage(img *entity.Image, extension extension.Type) (regi
return registered, true
}
-func normalizeImageForRegistration(bytes []byte, ext extension.Type) ([]byte, extension.Type, *entity.Dimensions, error) {
- if ext != extension.Svg {
- return bytes, ext, nil, nil
- }
- pngBytes, width, height, err := svgraster.Rasterize(bytes, 0, 0)
+func normalizeImageForRegistration(imageBytes []byte, ext extension.Type) ([]byte, extension.Type, *entity.Dimensions, error) {
+ normalized, err := imagecodec.NormalizeForPDF(imageBytes, ext)
if err != nil {
- return nil, "", nil, fmt.Errorf("svg rasterize: %w", err)
+ return nil, "", nil, err
+ }
+ var dimensions *entity.Dimensions
+ if normalized.HasDimensions() {
+ dimensions = &entity.Dimensions{
+ Width: float64(normalized.Width),
+ Height: float64(normalized.Height),
+ }
}
- return pngBytes, extension.Png, &entity.Dimensions{
- Width: float64(width),
- Height: float64(height),
- }, nil
+ return normalized.Bytes, normalized.Extension, dimensions, nil
}
func (s *Image) addImageToPdf(imageLabel string, info *gofpdf.ImageInfoType, cell *entity.Cell, margins *entity.Margins,
diff --git a/internal/providers/paper/line.go b/internal/providers/paper/line.go
index 91340215..3bff4d92 100644
--- a/internal/providers/paper/line.go
+++ b/internal/providers/paper/line.go
@@ -37,9 +37,7 @@ func (l *Line) renderVertical(cell *entity.Cell, prop *props.Line) {
left, top, _, _ := l.pdf.GetMargins()
- if prop.Color != nil {
- l.pdf.SetDrawColor(prop.Color.Red, prop.Color.Green, prop.Color.Blue)
- }
+ setPDFDrawColor(l.pdf, prop.Color)
l.pdf.SetLineWidth(prop.Thickness)
setDashPattern(l.pdf, prop.Style)
@@ -47,7 +45,7 @@ func (l *Line) renderVertical(cell *entity.Cell, prop *props.Line) {
l.pdf.Line(left+cell.X+position, top+cell.Y+space, left+cell.X+position, top+cell.Y+cell.Height-space)
if prop.Color != nil {
- l.pdf.SetDrawColor(l.defaultColor.Red, l.defaultColor.Green, l.defaultColor.Blue)
+ setPDFDrawColor(l.pdf, l.defaultColor)
}
l.pdf.SetLineWidth(l.defaultThickness)
resetDashPattern(l.pdf, prop.Style)
@@ -61,9 +59,7 @@ func (l *Line) renderHorizontal(cell *entity.Cell, prop *props.Line) {
left, top, _, _ := l.pdf.GetMargins()
- if prop.Color != nil {
- l.pdf.SetDrawColor(prop.Color.Red, prop.Color.Green, prop.Color.Blue)
- }
+ setPDFDrawColor(l.pdf, prop.Color)
l.pdf.SetLineWidth(prop.Thickness)
setDashPattern(l.pdf, prop.Style)
@@ -71,7 +67,7 @@ func (l *Line) renderHorizontal(cell *entity.Cell, prop *props.Line) {
l.pdf.Line(left+cell.X+space, top+cell.Y+position, left+cell.X+cell.Width-space, top+cell.Y+position)
if prop.Color != nil {
- l.pdf.SetDrawColor(l.defaultColor.Red, l.defaultColor.Green, l.defaultColor.Blue)
+ setPDFDrawColor(l.pdf, l.defaultColor)
}
l.pdf.SetLineWidth(l.defaultThickness)
resetDashPattern(l.pdf, prop.Style)
diff --git a/internal/providers/paper/pdf_interfaces.go b/internal/providers/paper/pdf_interfaces.go
index 5b512e85..a8e5d4d3 100644
--- a/internal/providers/paper/pdf_interfaces.go
+++ b/internal/providers/paper/pdf_interfaces.go
@@ -116,6 +116,7 @@ type checkboxPDF interface {
Line(x1, y1, x2, y2 float64)
Rect(x, y, w, h float64, styleStr string)
Text(x, y float64, txtStr string)
+ UnicodeTranslatorFromDescriptor(cpStr string) func(string) string
}
type gradientPDF interface {
diff --git a/internal/providers/paper/provider.go b/internal/providers/paper/provider.go
index 0d9d4241..985570b6 100644
--- a/internal/providers/paper/provider.go
+++ b/internal/providers/paper/provider.go
@@ -76,7 +76,7 @@ type provider struct {
// New is the constructor of provider for gofpdf.
func New(dep *Dependencies) core.Provider {
richText, _ := dep.Text.(*Text)
- return &provider{
+ p := &provider{
fpdf: asProviderPDF[providerPDF](dep.PDF),
transformPDF: asProviderPDF[providerTransformPDF](dep.PDF),
documentPDF: asProviderPDF[providerDocumentPDF](dep.PDF),
@@ -93,6 +93,14 @@ func New(dep *Dependencies) core.Provider {
cfg: dep.Cfg,
cache: dep.Cache,
}
+ if richText != nil {
+ // Surface text render fallbacks (e.g. glyphs the core-font code page
+ // cannot encode) through the provider's render issue report.
+ richText.SetRenderIssueSink(func(operation, message string) {
+ p.recordRenderIssue(operation, message, nil)
+ })
+ }
+ return p
}
func asProviderPDF[T any](pdf any) T {
diff --git a/internal/providers/paper/provider_catalog.go b/internal/providers/paper/provider_catalog.go
new file mode 100644
index 00000000..c735f449
--- /dev/null
+++ b/internal/providers/paper/provider_catalog.go
@@ -0,0 +1,232 @@
+package paper
+
+import (
+ pdf "github.com/avdoseferovic/paper/internal/pdf"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+)
+
+// catalogPDF is the subset of *pdf.PDF used to push document-catalog
+// features (forms, PDF/A, tagged PDF, viewer hints, attachments, ...).
+type catalogPDF interface {
+ SetAcroFormFields(fields ...pdf.FormField)
+ SetPageAnnotations(annotations ...pdf.PageAnnotation)
+ SetPageGeometries(geometries ...pdf.PageGeometry)
+ SetPdfA(config pdf.PdfAConfig)
+ SetTaggedPDF(enabled bool)
+ SetLanguage(language string)
+ SetViewerPreferences(prefs pdf.ViewerPreferences)
+ SetPageLabels(labels ...pdf.PageLabelRange)
+ SetAttachments(attachments ...pdf.FileAttachment)
+ SetNamedDestinations(destinations ...pdf.NamedDestination)
+ SetFileID(id []byte)
+ SetDeterministic(deterministic bool)
+}
+
+// SetDocumentCatalog pushes the document-catalog features from the config
+// into the underlying PDF writer.
+func (g *provider) SetDocumentCatalog(cfg *entity.Config) {
+ if cfg == nil {
+ return
+ }
+ target, ok := any(g.fpdf).(catalogPDF)
+ if !ok {
+ return
+ }
+ if cfg.AcroForm != nil {
+ target.SetAcroFormFields(pdfFormFields(cfg.AcroForm.Fields())...)
+ }
+ if len(cfg.Annotations) > 0 {
+ target.SetPageAnnotations(pdfAnnotations(cfg.Annotations)...)
+ }
+ if len(cfg.PageGeometries) > 0 {
+ target.SetPageGeometries(pdfPageGeometries(cfg.PageGeometries)...)
+ }
+ if cfg.PdfA != nil {
+ target.SetPdfA(pdfPdfAConfig(cfg.PdfA))
+ }
+ if cfg.TaggedPDF {
+ target.SetTaggedPDF(true)
+ }
+ if cfg.Language != "" {
+ target.SetLanguage(cfg.Language)
+ }
+ if cfg.ViewerPreferences != nil {
+ target.SetViewerPreferences(pdfViewerPreferences(cfg.ViewerPreferences))
+ }
+ if len(cfg.PageLabels) > 0 {
+ target.SetPageLabels(pdfPageLabels(cfg.PageLabels)...)
+ }
+ if len(cfg.Attachments) > 0 {
+ target.SetAttachments(pdfAttachments(cfg.Attachments)...)
+ }
+ if len(cfg.NamedDestinations) > 0 {
+ target.SetNamedDestinations(pdfNamedDestinations(cfg.NamedDestinations)...)
+ }
+ if len(cfg.FileID) > 0 {
+ target.SetFileID(cfg.FileID)
+ }
+ if cfg.Deterministic {
+ target.SetDeterministic(true)
+ }
+}
+
+func pdfFormFields(fields []*entity.Field) []pdf.FormField {
+ out := make([]pdf.FormField, 0, len(fields))
+ for _, field := range fields {
+ if field == nil {
+ continue
+ }
+ out = append(out, pdfFormField(field))
+ }
+ return out
+}
+
+func pdfFormField(field *entity.Field) pdf.FormField {
+ converted := pdf.FormField{
+ Name: field.Name,
+ Type: pdf.FormFieldType(field.Type),
+ Value: field.Value,
+ Default: field.Default,
+ Flags: pdf.FormFieldFlags(field.Flags),
+ Rect: field.Rect,
+ PageIndex: field.PageIndex,
+ FontSize: field.FontSize,
+ FontName: field.FontName,
+ TextColor: field.TextColor,
+ BGColor: field.BGColor,
+ BorderColor: field.BorderColor,
+ BorderWidth: field.BorderWidth,
+ Options: append([]string(nil), field.Options...),
+ ExportValue: field.ExportValue,
+ }
+ for _, child := range field.Children() {
+ if child == nil {
+ continue
+ }
+ converted.Children = append(converted.Children, pdfFormField(child))
+ }
+ return converted
+}
+
+func pdfAnnotations(annotations []entity.PageAnnotation) []pdf.PageAnnotation {
+ out := make([]pdf.PageAnnotation, 0, len(annotations))
+ for _, annotation := range annotations {
+ out = append(out, pdf.PageAnnotation{
+ PageIndex: annotation.PageIndex,
+ Subtype: string(annotation.Type),
+ Rect: annotation.Rect,
+ URI: annotation.URI,
+ DestName: annotation.DestName,
+ DestPage: annotation.DestPage,
+ Contents: annotation.Contents,
+ Name: annotation.Icon,
+ Open: annotation.Open,
+ Color: annotation.Color,
+ QuadPoints: annotation.QuadPoints,
+ })
+ }
+ return out
+}
+
+func pdfPageGeometries(geometries []entity.PageGeometry) []pdf.PageGeometry {
+ out := make([]pdf.PageGeometry, 0, len(geometries))
+ for _, geometry := range geometries {
+ out = append(out, pdf.PageGeometry{
+ PageIndex: geometry.PageIndex,
+ Rotate: geometry.Rotate,
+ CropBox: geometry.CropBox,
+ BleedBox: geometry.BleedBox,
+ TrimBox: geometry.TrimBox,
+ ArtBox: geometry.ArtBox,
+ })
+ }
+ return out
+}
+
+func pdfPdfAConfig(config *entity.PdfAConfig) pdf.PdfAConfig {
+ converted := pdf.PdfAConfig{
+ Level: pdf.PdfALevel(config.Level),
+ ICCProfile: config.ICCProfile,
+ OutputCondition: config.OutputCondition,
+ }
+ for _, schema := range config.XMPSchemas {
+ convertedSchema := pdf.XMPSchema{
+ Schema: schema.Schema,
+ NamespaceURI: schema.NamespaceURI,
+ Prefix: schema.Prefix,
+ }
+ for _, property := range schema.Properties {
+ convertedSchema.Properties = append(convertedSchema.Properties, pdf.XMPSchemaProperty(property))
+ }
+ converted.XMPSchemas = append(converted.XMPSchemas, convertedSchema)
+ }
+ for _, block := range config.XMPProperties {
+ convertedBlock := pdf.XMPPropertyBlock{
+ Namespace: block.Namespace,
+ Prefix: block.Prefix,
+ }
+ for _, property := range block.Properties {
+ convertedBlock.Properties = append(convertedBlock.Properties, pdf.XMPProperty(property))
+ }
+ converted.XMPProperties = append(converted.XMPProperties, convertedBlock)
+ }
+ return converted
+}
+
+func pdfViewerPreferences(prefs *entity.ViewerPreferences) pdf.ViewerPreferences {
+ return pdf.ViewerPreferences{
+ PageLayout: string(prefs.PageLayout),
+ PageMode: string(prefs.PageMode),
+ HideToolbar: prefs.HideToolbar,
+ HideMenubar: prefs.HideMenubar,
+ HideWindowUI: prefs.HideWindowUI,
+ FitWindow: prefs.FitWindow,
+ CenterWindow: prefs.CenterWindow,
+ DisplayDocTitle: prefs.DisplayDocTitle,
+ OpenPage: prefs.OpenPage,
+ OpenZoom: prefs.OpenZoom,
+ }
+}
+
+func pdfPageLabels(labels []entity.PageLabelRange) []pdf.PageLabelRange {
+ out := make([]pdf.PageLabelRange, 0, len(labels))
+ for _, label := range labels {
+ out = append(out, pdf.PageLabelRange{
+ PageIndex: label.PageIndex,
+ Style: string(label.Style),
+ Prefix: label.Prefix,
+ Start: label.Start,
+ })
+ }
+ return out
+}
+
+func pdfAttachments(attachments []entity.FileAttachment) []pdf.FileAttachment {
+ out := make([]pdf.FileAttachment, 0, len(attachments))
+ for _, attachment := range attachments {
+ out = append(out, pdf.FileAttachment{
+ FileName: attachment.FileName,
+ MIMEType: attachment.MIMEType,
+ Description: attachment.Description,
+ AFRelationship: attachment.AFRelationship,
+ Data: attachment.Data,
+ CreationDate: attachment.CreationDate,
+ })
+ }
+ return out
+}
+
+func pdfNamedDestinations(destinations []entity.NamedDestination) []pdf.NamedDestination {
+ out := make([]pdf.NamedDestination, 0, len(destinations))
+ for _, destination := range destinations {
+ out = append(out, pdf.NamedDestination{
+ Name: destination.Name,
+ PageIndex: destination.PageIndex,
+ FitType: string(destination.FitType),
+ Top: destination.Top,
+ Left: destination.Left,
+ Zoom: destination.Zoom,
+ })
+ }
+ return out
+}
diff --git a/internal/providers/paper/richtext.go b/internal/providers/paper/richtext.go
index 7ecd3259..1fa27762 100644
--- a/internal/providers/paper/richtext.go
+++ b/internal/providers/paper/richtext.go
@@ -2,7 +2,6 @@ package paper
import (
"strings"
- "unicode"
"github.com/avdoseferovic/paper/pkg/consts"
"github.com/avdoseferovic/paper/pkg/consts/fontstyle"
@@ -93,6 +92,11 @@ func (s *Text) AddRichText(runs []props.RichRun, cell *entity.Cell, prop *props.
whiteSpace := normalizeRichTextWhiteSpace(prop.WhiteSpace)
+ // Collect glyphs the code page translation replaced, deduped across all
+ // tokens, so one render issue is recorded per AddRichText call.
+ var missing []rune
+ seenMissing := make(map[rune]bool)
+
tokens, lineWidths := layoutRichTextTokens(resolved, richTextLayoutInput{
prop: prop,
width: width,
@@ -100,9 +104,18 @@ func (s *Text) AddRichText(runs []props.RichRun, cell *entity.Cell, prop *props.
measure: func(r resolvedRun, text string) (string, float64) {
s.font.SetFont(r.Family, r.styleWithUnderline(), r.Size)
translated := s.translateUnicode(text, r.Family)
+ if s.issueSink != nil {
+ for _, missingRune := range unsupportedGlyphs(text, translated) {
+ if !seenMissing[missingRune] {
+ seenMissing[missingRune] = true
+ missing = append(missing, missingRune)
+ }
+ }
+ }
return translated, s.pdf.GetStringWidth(translated)
},
})
+ s.reportMissingRunes(missing)
s.renderRichTextTokens(tokens, lineWidths, resolved, cell, prop, lineHeight, lineMultiplier, origColor)
}
@@ -227,229 +240,6 @@ func richTextLineCount(tokens []rtToken) int {
return maxLine + 1
}
-// rtToken is the per-word state used by AddRichText's three-pass layout.
-type rtToken struct {
- text string
- translated string
- runIdx int
- width float64
- x float64
- right float64
- lineY int
- isBreak bool
- skip bool
- skipAtLineStart bool
- // gluePrev marks a word that continues the previous word token with no
- // whitespace between them (e.g. `Stress?`). CSS defines no
- // break opportunity there, so layout wraps the glued sequence as one unit.
- gluePrev bool
-}
-
-func (t rtToken) isImage(run resolvedRun) bool {
- return run.Image != nil && t.text == "" && !t.isBreak
-}
-
-func (t rtToken) isFixedInlineBox(run resolvedRun) bool {
- return run.hasFixedInlineBox() && t.text == "" && !t.isBreak
-}
-
-// tokeniseRuns splits the resolved run sequence into renderable text spans,
-// preserving or collapsing whitespace according to CSS white-space semantics.
-func tokeniseRuns(runs []resolvedRun, whiteSpace string) []rtToken {
- var out []rtToken
- pendingCollapsedSpace := false
- pendingCollapsedSpaceRunIdx := -1
- for i, r := range runs {
- if r.ForceBreak {
- out = append(out, rtToken{runIdx: i, isBreak: true})
- pendingCollapsedSpace = false
- pendingCollapsedSpaceRunIdx = -1
- continue
- }
- if r.Image != nil {
- if pendingCollapsedSpace && hasTextOnCurrentLine(out) {
- out = append(out, rtToken{text: " ", runIdx: pendingCollapsedSpaceRunIdx, skipAtLineStart: true})
- }
- pendingCollapsedSpace = false
- pendingCollapsedSpaceRunIdx = -1
- out = append(out, rtToken{runIdx: i})
- continue
- }
- if r.hasFixedInlineBox() && r.Text == "" {
- if pendingCollapsedSpace && hasTextOnCurrentLine(out) {
- out = append(out, rtToken{text: " ", runIdx: pendingCollapsedSpaceRunIdx, skipAtLineStart: true})
- }
- pendingCollapsedSpace = false
- pendingCollapsedSpaceRunIdx = -1
- out = append(out, rtToken{runIdx: i})
- continue
- }
- runWhiteSpace := normalizeRichTextWhiteSpace(r.WhiteSpace)
- switch {
- case runWhiteSpace == richTextWhiteSpaceNoWrap:
- out, pendingCollapsedSpace, pendingCollapsedSpaceRunIdx = appendCollapsedNowrapToken(
- out,
- r.Text,
- i,
- pendingCollapsedSpace,
- pendingCollapsedSpaceRunIdx,
- )
- case whiteSpace == richTextWhiteSpacePre || whiteSpace == richTextWhiteSpacePreWrap:
- out = append(out, tokenisePreservedText(r.Text, i)...)
- case whiteSpace == richTextWhiteSpacePreLine:
- out, pendingCollapsedSpace, pendingCollapsedSpaceRunIdx = appendCollapsedTokens(
- out,
- r.Text,
- i,
- true,
- pendingCollapsedSpace,
- pendingCollapsedSpaceRunIdx,
- )
- default:
- out, pendingCollapsedSpace, pendingCollapsedSpaceRunIdx = appendCollapsedTokens(
- out,
- r.Text,
- i,
- false,
- pendingCollapsedSpace,
- pendingCollapsedSpaceRunIdx,
- )
- }
- }
- return out
-}
-
-func appendCollapsedNowrapToken(
- out []rtToken,
- text string,
- runIdx int,
- pendingSpace bool,
- pendingSpaceRunIdx int,
-) ([]rtToken, bool, int) {
- before := len(out)
- out, pendingSpace, pendingSpaceRunIdx = appendCollapsedTokens(out, text, runIdx, false, pendingSpace, pendingSpaceRunIdx)
- if len(out) == before {
- return out, pendingSpace, pendingSpaceRunIdx
- }
-
- merged := make([]rtToken, 0, len(out))
- merged = append(merged, out[:before]...)
- var b strings.Builder
- glue := false
- flush := func() {
- if b.Len() == 0 {
- return
- }
- merged = append(merged, rtToken{text: b.String(), runIdx: runIdx, gluePrev: glue})
- b.Reset()
- glue = false
- }
- for _, t := range out[before:] {
- if !t.isBreak && t.text != "" && t.runIdx == runIdx {
- if b.Len() == 0 {
- glue = t.gluePrev
- }
- b.WriteString(t.text)
- continue
- }
- flush()
- merged = append(merged, t)
- }
- flush()
- return merged, pendingSpace, pendingSpaceRunIdx
-}
-
-func appendCollapsedTokens(
- out []rtToken,
- text string,
- runIdx int,
- preserveNewlines bool,
- pendingSpace bool,
- pendingSpaceRunIdx int,
-) ([]rtToken, bool, int) {
- var b strings.Builder
- flushWord := func() {
- if b.Len() == 0 {
- return
- }
- if pendingSpace && hasTextOnCurrentLine(out) {
- out = append(out, rtToken{text: " ", runIdx: pendingSpaceRunIdx, skipAtLineStart: true})
- }
- glue := !pendingSpace && endsInGlueableWord(out)
- pendingSpace = false
- pendingSpaceRunIdx = -1
- out = append(out, rtToken{text: b.String(), runIdx: runIdx, gluePrev: glue})
- b.Reset()
- }
- for _, r := range text {
- if r == '\n' && preserveNewlines {
- flushWord()
- out = append(out, rtToken{runIdx: runIdx, isBreak: true})
- pendingSpace = false
- pendingSpaceRunIdx = -1
- continue
- }
- if isCollapsibleRichSpace(r) {
- flushWord()
- pendingSpace = true
- pendingSpaceRunIdx = runIdx
- continue
- }
- b.WriteRune(r)
- }
- flushWord()
- return out, pendingSpace, pendingSpaceRunIdx
-}
-
-func isCollapsibleRichSpace(r rune) bool {
- return unicode.IsSpace(r) && r != '\u00a0'
-}
-
-// endsInGlueableWord reports whether the last token is a word another word can
-// glue to. Images and fixed inline boxes are replaced elements \u2014 CSS allows
-// breaks around those even without whitespace \u2014 so only text words glue.
-func endsInGlueableWord(tokens []rtToken) bool {
- if len(tokens) == 0 {
- return false
- }
- last := tokens[len(tokens)-1]
- return !last.isBreak && last.text != "" && last.text != " "
-}
-
-func tokenisePreservedText(text string, runIdx int) []rtToken {
- var out []rtToken
- var b strings.Builder
- flush := func() {
- if b.Len() == 0 {
- return
- }
- out = append(out, rtToken{text: b.String(), runIdx: runIdx})
- b.Reset()
- }
- for _, r := range text {
- if r == '\n' {
- flush()
- out = append(out, rtToken{runIdx: runIdx, isBreak: true})
- continue
- }
- b.WriteRune(r)
- }
- flush()
- return out
-}
-
-func hasTextOnCurrentLine(tokens []rtToken) bool {
- for i := len(tokens) - 1; i >= 0; i-- {
- if tokens[i].isBreak {
- return false
- }
- if tokens[i].text != "" {
- return true
- }
- }
- return false
-}
-
func firstXForLine(lineY int, firstLineIndent float64) float64 {
if lineY == 0 && firstLineIndent > 0 {
return firstLineIndent
@@ -507,17 +297,3 @@ func (r resolvedRun) styleWithUnderline() fontstyle.Type {
func (r resolvedRun) hasFixedInlineBox() bool {
return r.InlineBoxWidth > 0 || r.InlineBoxHeight > 0
}
-
-// translateUnicode applies the gofpdf Unicode translator for built-in font families
-// (Arial, Helvetica, Courier, Symbol, ZapBats) which expect Latin-1 codepoints.
-// Comparison is case-insensitive because callers commonly use "Helvetica" while
-// the fontfamily constants are lowercase. For custom (UTF-8) fonts text passes through.
-func (s *Text) translateUnicode(text, family string) string {
- switch strings.ToLower(family) {
- case consts.FontFamilyArial, consts.FontFamilyHelvetica, consts.FontFamilySymbol,
- consts.FontFamilyZapBats, consts.FontFamilyCourier:
- return s.translateDefaultCodePage(text)
- default:
- return text
- }
-}
diff --git a/internal/providers/paper/richtext_layout.go b/internal/providers/paper/richtext_layout.go
index 55cc2a88..1d066d81 100644
--- a/internal/providers/paper/richtext_layout.go
+++ b/internal/providers/paper/richtext_layout.go
@@ -94,6 +94,8 @@ func layoutRichTextTokens(runs []resolvedRun, input richTextLayoutInput) ([]rtTo
i = groupEnd
}
+ hangTrailingWrapSpaces(tokens)
+
lineWidths := lineWidths(tokens)
if input.prop != nil && input.prop.Align == consts.AlignJustify {
justifyRichTextLines(tokens, lineWidths, input.width)
@@ -101,6 +103,28 @@ func layoutRichTextTokens(runs []resolvedRun, input richTextLayoutInput) ([]rtTo
return tokens, lineWidths
}
+// hangTrailingWrapSpaces marks collapse-mode space tokens that end a line
+// (i.e. the following word wrapped or a forced break follows) as skipped.
+// Browsers hang spaces at a wrap point: they contribute neither to the
+// measured line width used for right/center alignment nor to justification.
+// Only collapse-mode spaces hang — preserved whitespace (white-space: pre /
+// pre-wrap) is tokenised inside word tokens and is never affected; the
+// skipAtLineStart flag identifies collapse-mode spaces.
+func hangTrailingWrapSpaces(tokens []rtToken) {
+ lastOnLine := make(map[int]int)
+ for i := range tokens {
+ if tokens[i].isBreak || tokens[i].skip {
+ continue
+ }
+ lastOnLine[tokens[i].lineY] = i
+ }
+ for _, i := range lastOnLine {
+ if tokens[i].text == " " && tokens[i].skipAtLineStart {
+ tokens[i].skip = true
+ }
+ }
+}
+
func tokenGroupAdvance(tokens []rtToken, runs []resolvedRun, boxStart, boxEnd, runStart, runEnd []bool, start, end int) float64 {
total := 0.0
for i := start; i < end; i++ {
diff --git a/internal/providers/paper/richtext_layout_test.go b/internal/providers/paper/richtext_layout_test.go
index cfc4c3f6..394ac5b1 100644
--- a/internal/providers/paper/richtext_layout_test.go
+++ b/internal/providers/paper/richtext_layout_test.go
@@ -31,8 +31,11 @@ func TestLayoutRichTextTokensWrapsAndPreservesOrder(t *testing.T) {
assert.Equal(t, 1, tokens[2].lineY)
assert.Equal(t, "gamma", tokens[4].text)
assert.Equal(t, 2, tokens[4].lineY)
- assert.Equal(t, 6.0, lineWidths[0])
- assert.Equal(t, 5.0, lineWidths[1])
+ // SEMANTICS CHANGE: line widths previously included the trailing space left
+ // behind at each wrap point (6.0 and 5.0). Spaces at a wrap point now hang
+ // (browser behavior), so measured widths cover the words only.
+ assert.Equal(t, 5.0, lineWidths[0])
+ assert.Equal(t, 4.0, lineWidths[1])
assert.Equal(t, 5.0, lineWidths[2])
}
@@ -337,6 +340,55 @@ func TestLayoutRichTextTokensJustifiesWrappedLines(t *testing.T) {
assert.Equal(t, 2.0, lineWidths[1])
}
+func TestLayoutRichTextTokensHangsTrailingSpaceAtWrapPoint(t *testing.T) {
+ t.Parallel()
+
+ // "aa bb " fits width 6 exactly including the trailing space, so the space
+ // stays on line 0 while "cc" wraps. Browsers drop (hang) collapse-mode
+ // spaces at a wrap point: the space must not count towards the measured
+ // line width used for right/center alignment.
+ runs := []resolvedRun{{RichRun: props.RichRun{Text: "aa bb cc"}}}
+ tokens, lineWidths := layoutRichTextTokens(runs, richTextLayoutInput{
+ prop: &props.RichText{Align: consts.AlignRight},
+ width: 6,
+ whiteSpace: "normal",
+ measure: func(_ resolvedRun, text string) (string, float64) {
+ return text, float64(len(text))
+ },
+ })
+
+ require.Len(t, tokens, 5)
+ assert.Equal(t, " ", tokens[3].text)
+ assert.Equal(t, 0, tokens[3].lineY)
+ assert.True(t, tokens[3].skip, "trailing space at the wrap point should hang and not render")
+ assert.Equal(t, 5.0, lineWidths[0], "hanging space must not inflate the measured line width")
+ assert.Equal(t, 2.0, lineWidths[1])
+}
+
+func TestLayoutRichTextTokensJustifyIgnoresTrailingSpaceAtWrapPoint(t *testing.T) {
+ t.Parallel()
+
+ // Same wrap as above but justified: the slack must be distributed into the
+ // interior space only, never dumped into the invisible trailing space.
+ runs := []resolvedRun{{RichRun: props.RichRun{Text: "aa bb cc"}}}
+ tokens, lineWidths := layoutRichTextTokens(runs, richTextLayoutInput{
+ prop: &props.RichText{Align: consts.AlignJustify},
+ width: 6,
+ whiteSpace: "normal",
+ measure: func(_ resolvedRun, text string) (string, float64) {
+ return text, float64(len(text))
+ },
+ })
+
+ require.Len(t, tokens, 5)
+ assert.True(t, tokens[3].skip, "trailing space at the wrap point should hang")
+ // slack = 6 - 5 = 1, all of it into the single interior space
+ assert.Equal(t, 2.0, tokens[1].width, "interior space absorbs the full slack")
+ assert.Equal(t, 4.0, tokens[2].x, "'bb' shifts right by the expanded space")
+ assert.Equal(t, 6.0, lineWidths[0])
+ assert.Equal(t, 0.0, tokens[4].x, "last line stays start-aligned")
+}
+
func TestLayoutRichTextTokensTreatsOnlyEmptyImageTokenAsImage(t *testing.T) {
t.Parallel()
diff --git a/internal/providers/paper/richtext_render.go b/internal/providers/paper/richtext_render.go
index ccd89938..f21f81e5 100644
--- a/internal/providers/paper/richtext_render.go
+++ b/internal/providers/paper/richtext_render.go
@@ -163,7 +163,7 @@ func (s *Text) drawRunBackground(r resolvedRun, x, yTop, w, lineHeight float64)
translucent := false
if r.Background != nil {
drawMode = "F"
- s.pdf.SetFillColor(r.Background.Red, r.Background.Green, r.Background.Blue)
+ setPDFFillColor(s.pdf, r.Background)
translucent = r.Background.Alpha != nil && *r.Background.Alpha < 1
}
if r.BorderColor != nil && r.BorderWidth > 0 {
@@ -385,7 +385,7 @@ func (s *Text) renderTokenShadows(r resolvedRun, origColor *props.Color, x, y fl
continue
}
sc := shadow.Color
- s.pdf.SetTextColor(sc.Red, sc.Green, sc.Blue)
+ setPDFTextColor(s.pdf, sc)
s.pdf.Text(x+shadow.OffsetX, y+shadow.OffsetY, translated)
painted = true
}
@@ -394,9 +394,9 @@ func (s *Text) renderTokenShadows(r resolvedRun, origColor *props.Color, x, y fl
}
// Restore run colour before drawing normal text.
if r.Color != nil {
- s.pdf.SetTextColor(r.Color.Red, r.Color.Green, r.Color.Blue)
+ setPDFTextColor(s.pdf, r.Color)
} else {
- s.pdf.SetTextColor(origColor.Red, origColor.Green, origColor.Blue)
+ setPDFTextColor(s.pdf, origColor)
}
}
diff --git a/internal/providers/paper/richtext_tokens.go b/internal/providers/paper/richtext_tokens.go
new file mode 100644
index 00000000..805234ce
--- /dev/null
+++ b/internal/providers/paper/richtext_tokens.go
@@ -0,0 +1,229 @@
+package paper
+
+import (
+ "strings"
+ "unicode"
+)
+
+// rtToken is the per-word state used by AddRichText's three-pass layout.
+type rtToken struct {
+ text string
+ translated string
+ runIdx int
+ width float64
+ x float64
+ right float64
+ lineY int
+ isBreak bool
+ skip bool
+ skipAtLineStart bool
+ // gluePrev marks a word that continues the previous word token with no
+ // whitespace between them (e.g. `Stress?`). CSS defines no
+ // break opportunity there, so layout wraps the glued sequence as one unit.
+ gluePrev bool
+}
+
+func (t rtToken) isImage(run resolvedRun) bool {
+ return run.Image != nil && t.text == "" && !t.isBreak
+}
+
+func (t rtToken) isFixedInlineBox(run resolvedRun) bool {
+ return run.hasFixedInlineBox() && t.text == "" && !t.isBreak
+}
+
+// tokeniseRuns splits the resolved run sequence into renderable text spans,
+// preserving or collapsing whitespace according to CSS white-space semantics.
+func tokeniseRuns(runs []resolvedRun, whiteSpace string) []rtToken {
+ var out []rtToken
+ pendingCollapsedSpace := false
+ pendingCollapsedSpaceRunIdx := -1
+ for i, r := range runs {
+ if r.ForceBreak {
+ out = append(out, rtToken{runIdx: i, isBreak: true})
+ pendingCollapsedSpace = false
+ pendingCollapsedSpaceRunIdx = -1
+ continue
+ }
+ if r.Image != nil {
+ if pendingCollapsedSpace && hasTextOnCurrentLine(out) {
+ out = append(out, rtToken{text: " ", runIdx: pendingCollapsedSpaceRunIdx, skipAtLineStart: true})
+ }
+ pendingCollapsedSpace = false
+ pendingCollapsedSpaceRunIdx = -1
+ out = append(out, rtToken{runIdx: i})
+ continue
+ }
+ if r.hasFixedInlineBox() && r.Text == "" {
+ if pendingCollapsedSpace && hasTextOnCurrentLine(out) {
+ out = append(out, rtToken{text: " ", runIdx: pendingCollapsedSpaceRunIdx, skipAtLineStart: true})
+ }
+ pendingCollapsedSpace = false
+ pendingCollapsedSpaceRunIdx = -1
+ out = append(out, rtToken{runIdx: i})
+ continue
+ }
+ runWhiteSpace := normalizeRichTextWhiteSpace(r.WhiteSpace)
+ switch {
+ case runWhiteSpace == richTextWhiteSpaceNoWrap:
+ out, pendingCollapsedSpace, pendingCollapsedSpaceRunIdx = appendCollapsedNowrapToken(
+ out,
+ r.Text,
+ i,
+ pendingCollapsedSpace,
+ pendingCollapsedSpaceRunIdx,
+ )
+ case whiteSpace == richTextWhiteSpacePre || whiteSpace == richTextWhiteSpacePreWrap:
+ out = append(out, tokenisePreservedText(r.Text, i)...)
+ case whiteSpace == richTextWhiteSpacePreLine:
+ out, pendingCollapsedSpace, pendingCollapsedSpaceRunIdx = appendCollapsedTokens(
+ out,
+ r.Text,
+ i,
+ true,
+ pendingCollapsedSpace,
+ pendingCollapsedSpaceRunIdx,
+ )
+ default:
+ out, pendingCollapsedSpace, pendingCollapsedSpaceRunIdx = appendCollapsedTokens(
+ out,
+ r.Text,
+ i,
+ false,
+ pendingCollapsedSpace,
+ pendingCollapsedSpaceRunIdx,
+ )
+ }
+ }
+ return out
+}
+
+func appendCollapsedNowrapToken(
+ out []rtToken,
+ text string,
+ runIdx int,
+ pendingSpace bool,
+ pendingSpaceRunIdx int,
+) ([]rtToken, bool, int) {
+ before := len(out)
+ out, pendingSpace, pendingSpaceRunIdx = appendCollapsedTokens(out, text, runIdx, false, pendingSpace, pendingSpaceRunIdx)
+ if len(out) == before {
+ return out, pendingSpace, pendingSpaceRunIdx
+ }
+
+ merged := make([]rtToken, 0, len(out))
+ merged = append(merged, out[:before]...)
+ var b strings.Builder
+ glue := false
+ flush := func() {
+ if b.Len() == 0 {
+ return
+ }
+ merged = append(merged, rtToken{text: b.String(), runIdx: runIdx, gluePrev: glue})
+ b.Reset()
+ glue = false
+ }
+ for _, t := range out[before:] {
+ if !t.isBreak && t.text != "" && t.runIdx == runIdx {
+ if b.Len() == 0 {
+ glue = t.gluePrev
+ }
+ b.WriteString(t.text)
+ continue
+ }
+ flush()
+ merged = append(merged, t)
+ }
+ flush()
+ return merged, pendingSpace, pendingSpaceRunIdx
+}
+
+func appendCollapsedTokens(
+ out []rtToken,
+ text string,
+ runIdx int,
+ preserveNewlines bool,
+ pendingSpace bool,
+ pendingSpaceRunIdx int,
+) ([]rtToken, bool, int) {
+ var b strings.Builder
+ flushWord := func() {
+ if b.Len() == 0 {
+ return
+ }
+ if pendingSpace && hasTextOnCurrentLine(out) {
+ out = append(out, rtToken{text: " ", runIdx: pendingSpaceRunIdx, skipAtLineStart: true})
+ }
+ glue := !pendingSpace && endsInGlueableWord(out)
+ pendingSpace = false
+ pendingSpaceRunIdx = -1
+ out = append(out, rtToken{text: b.String(), runIdx: runIdx, gluePrev: glue})
+ b.Reset()
+ }
+ for _, r := range text {
+ if r == '\n' && preserveNewlines {
+ flushWord()
+ out = append(out, rtToken{runIdx: runIdx, isBreak: true})
+ pendingSpace = false
+ pendingSpaceRunIdx = -1
+ continue
+ }
+ if isCollapsibleRichSpace(r) {
+ flushWord()
+ pendingSpace = true
+ pendingSpaceRunIdx = runIdx
+ continue
+ }
+ b.WriteRune(r)
+ }
+ flushWord()
+ return out, pendingSpace, pendingSpaceRunIdx
+}
+
+func isCollapsibleRichSpace(r rune) bool {
+ return unicode.IsSpace(r) && r != '\u00a0'
+}
+
+// endsInGlueableWord reports whether the last token is a word another word can
+// glue to. Images and fixed inline boxes are replaced elements \u2014 CSS allows
+// breaks around those even without whitespace \u2014 so only text words glue.
+func endsInGlueableWord(tokens []rtToken) bool {
+ if len(tokens) == 0 {
+ return false
+ }
+ last := tokens[len(tokens)-1]
+ return !last.isBreak && last.text != "" && last.text != " "
+}
+
+func tokenisePreservedText(text string, runIdx int) []rtToken {
+ var out []rtToken
+ var b strings.Builder
+ flush := func() {
+ if b.Len() == 0 {
+ return
+ }
+ out = append(out, rtToken{text: b.String(), runIdx: runIdx})
+ b.Reset()
+ }
+ for _, r := range text {
+ if r == '\n' {
+ flush()
+ out = append(out, rtToken{runIdx: runIdx, isBreak: true})
+ continue
+ }
+ b.WriteRune(r)
+ }
+ flush()
+ return out
+}
+
+func hasTextOnCurrentLine(tokens []rtToken) bool {
+ for i := len(tokens) - 1; i >= 0; i-- {
+ if tokens[i].isBreak {
+ return false
+ }
+ if tokens[i].text != "" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/providers/paper/text.go b/internal/providers/paper/text.go
index 499cbc18..e31c0832 100644
--- a/internal/providers/paper/text.go
+++ b/internal/providers/paper/text.go
@@ -18,6 +18,9 @@ type Text struct {
font core.Font
layoutCache map[textLayoutKey][]string
defaultCodeTranslator func(string) string
+ // issueSink receives render fallback issues (e.g. glyphs the core-font
+ // code page cannot encode). Nil when no collector is wired.
+ issueSink func(operation, message string)
}
type textLayoutKey struct {
@@ -44,25 +47,19 @@ func (s *Text) Add(text string, cell *entity.Cell, textProp *props.Text) {
s.font.SetFont(textProp.Family, textProp.Style, textProp.Size)
fontHeight := s.font.GetHeight(textProp.Family, textProp.Style, textProp.Size)
- if textProp.Top > cell.Height {
- textProp.Top = cell.Height
- }
-
- if textProp.Left > cell.Width {
- textProp.Left = cell.Width
- }
-
- if textProp.Right > cell.Width {
- textProp.Right = cell.Width
- }
+ // Clamp on local copies: writing through textProp would leak the clamped
+ // values into the caller's persistent component prop.
+ top := min(textProp.Top, cell.Height)
+ left := min(textProp.Left, cell.Width)
+ right := min(textProp.Right, cell.Width)
- width := cell.Width - textProp.Left - textProp.Right
+ width := cell.Width - left - right
if width < 0 {
width = 0
}
- x := cell.X + textProp.Left
- y := cell.Y + textProp.Top
+ x := cell.X + left
+ y := cell.Y + top
originalColor := s.font.GetColor()
if textProp.Color != nil {
@@ -79,11 +76,12 @@ func (s *Text) Add(text string, cell *entity.Cell, textProp *props.Text) {
// Apply Unicode before calc spaces
unicodeText := s.textToUnicode(text, textProp)
+ s.reportUnsupportedGlyphs(text, unicodeText)
stringWidth := s.pdf.GetStringWidth(unicodeText)
// If should add one line
if stringWidth <= width {
- s.addLine(textProp, x, width, y, stringWidth, unicodeText)
+ s.addLine(textProp, x, width, y, stringWidth, unicodeText, true)
s.font.SetColor(originalColor)
return
}
@@ -95,7 +93,7 @@ func (s *Text) Add(text string, cell *entity.Cell, textProp *props.Text) {
for index, line := range lines {
lineWidth := s.pdf.GetStringWidth(line)
- s.addLine(textProp, x, width, y+float64(index)*fontHeight+accumulateOffsetY, lineWidth, line)
+ s.addLine(textProp, x, width, y+float64(index)*fontHeight+accumulateOffsetY, lineWidth, line, index == len(lines)-1)
accumulateOffsetY += textProp.VerticalPadding
}
@@ -109,7 +107,10 @@ func (s *Text) GetLinesQuantity(text string, textProp *props.Text, colWidth floa
textTranslated := s.textToUnicode(text, textProp)
if textProp.BreakLineStrategy == consts.BreakLineDash {
- lines := s.getLinesBreakingLineWithDash(text, colWidth)
+ // Break the TRANSLATED text: the render path (getCachedLines) operates
+ // on translated text, so caching raw-text lines under the translated key
+ // would make a later Add render untranslated glyphs.
+ lines := s.getLinesBreakingLineWithDash(textTranslated, colWidth)
s.setCachedLines(textTranslated, textProp, colWidth, lines)
return len(lines)
}
@@ -212,12 +213,15 @@ func (s *Text) getLinesBreakingLineWithDash(words string, colWidth float64) []st
return lines
}
-func (s *Text) addLine(textProp *props.Text, xColOffset, colWidth, yColOffset, textWidth float64, text string) {
+func (s *Text) addLine(textProp *props.Text, xColOffset, colWidth, yColOffset, textWidth float64, text string, lastLine bool) {
left, top, _, _ := s.pdf.GetMargins()
fontHeight := s.font.GetHeight(textProp.Family, textProp.Style, textProp.Size)
- if textProp.Align == consts.AlignLeft {
+ // CSS text-align: justify semantics: the last line of a paragraph (and a
+ // single line) is start-aligned, never stretched — matching
+ // justifyRichTextLines for rich text.
+ if textProp.Align == consts.AlignLeft || (textProp.Align == consts.AlignJustify && lastLine) {
s.pdf.Text(xColOffset+left, yColOffset+top, text)
if textProp.Hyperlink != nil {
@@ -275,22 +279,7 @@ func (s *Text) addLine(textProp *props.Text, xColOffset, colWidth, yColOffset, t
}
func (s *Text) textToUnicode(txt string, props *props.Text) string {
- if props.Family == consts.FontFamilyArial ||
- props.Family == consts.FontFamilyHelvetica ||
- props.Family == consts.FontFamilySymbol ||
- props.Family == consts.FontFamilyZapBats ||
- props.Family == consts.FontFamilyCourier {
- return s.translateDefaultCodePage(txt)
- }
-
- return txt
-}
-
-func (s *Text) translateDefaultCodePage(txt string) string {
- if s.defaultCodeTranslator == nil {
- s.defaultCodeTranslator = s.pdf.UnicodeTranslatorFromDescriptor("")
- }
- return s.defaultCodeTranslator(txt)
+ return s.translateUnicode(txt, props.Family)
}
func isIncorrectSpaceWidth(textWidth, spaceWidth, defaultSpaceWidth float64, text string) bool {
diff --git a/internal/providers/paper/text_issues_test.go b/internal/providers/paper/text_issues_test.go
new file mode 100644
index 00000000..cd089ef0
--- /dev/null
+++ b/internal/providers/paper/text_issues_test.go
@@ -0,0 +1,72 @@
+package paper_test
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ gofpdf "github.com/avdoseferovic/paper/internal/providers/paper"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/consts"
+ "github.com/avdoseferovic/paper/pkg/consts/fontstyle"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+// buildRealTextProvider builds a provider with the real builder (real gofpdf,
+// real cp1252 translator) so glyph fallbacks behave exactly as in production.
+func buildRealTextProvider(t *testing.T) core.Provider {
+ t.Helper()
+ fontProp := props.Font{Family: consts.FontFamilyArial, Style: fontstyle.Normal, Size: 10}
+ cfg := &entity.Config{
+ Dimensions: &entity.Dimensions{Width: 210, Height: 297},
+ Margins: &entity.Margins{Left: 10, Top: 10, Right: 10, Bottom: 10},
+ DefaultFont: &fontProp,
+ }
+ return gofpdf.New(gofpdf.NewBuilder().Build(cfg, nil))
+}
+
+func TestProvider_AddTextReportsUnsupportedGlyphs(t *testing.T) {
+ t.Parallel()
+
+ t.Run("when text contains glyphs outside cp1252, should record a render issue", func(t *testing.T) {
+ t.Parallel()
+ sut := buildRealTextProvider(t)
+ prop := &props.Text{Family: consts.FontFamilyArial, Style: fontstyle.Normal, Size: 10, Align: consts.AlignLeft}
+ cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 20}
+
+ sut.AddText("Zażółć", cell, prop)
+
+ issues := sut.(core.RenderIssueProvider).RenderIssues()
+ require.Len(t, issues, 1)
+ assert.Equal(t, "text.unsupported-glyphs", issues[0].Operation)
+ // cp1252 has "ó" but not "ż", "ł", "ć"
+ assert.Contains(t, issues[0].Message, "ż")
+ assert.Contains(t, issues[0].Message, "ł")
+ assert.Contains(t, issues[0].Message, "ć")
+ })
+
+ t.Run("when text is fully representable in cp1252, should record nothing", func(t *testing.T) {
+ t.Parallel()
+ sut := buildRealTextProvider(t)
+ prop := &props.Text{Family: consts.FontFamilyArial, Style: fontstyle.Normal, Size: 10, Align: consts.AlignLeft}
+ cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 20}
+
+ sut.AddText("café", cell, prop)
+
+ assert.Len(t, sut.(core.RenderIssueProvider).RenderIssues(), 0)
+ })
+
+ t.Run("when rich text contains glyphs outside cp1252, should record one deduped issue per add", func(t *testing.T) {
+ t.Parallel()
+ sut := buildRealTextProvider(t)
+ cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 20}
+ runs := []props.RichRun{{Text: "Zażółć zażółć"}}
+
+ sut.(core.RichTextProvider).AddRichText(runs, cell, &props.RichText{})
+
+ issues := sut.(core.RenderIssueProvider).RenderIssues()
+ require.Len(t, issues, 1)
+ assert.Equal(t, "text.unsupported-glyphs", issues[0].Operation)
+ })
+}
diff --git a/internal/providers/paper/text_test.go b/internal/providers/paper/text_test.go
index d600aab8..6b4aac2d 100644
--- a/internal/providers/paper/text_test.go
+++ b/internal/providers/paper/text_test.go
@@ -2,6 +2,7 @@ package paper_test
import (
"fmt"
+ "strings"
"testing"
mock "github.com/avdoseferovic/paper/internal/mocktest"
@@ -65,6 +66,60 @@ func TestGetLinesHeight(t *testing.T) {
assert.Equal(t, 2, height)
})
+
+ t.Run("when BreakLineDash with non-ASCII text, should cache translated lines so a later Add renders translated glyphs", func(t *testing.T) {
+ t.Parallel()
+ // Regression: GetLinesQuantity computed the dash-broken lines from the
+ // RAW text but cached them under the TRANSLATED key, so a later Add()
+ // hit the cache and rendered untranslated glyphs.
+ cell := &entity.Cell{X: 0, Y: 0, Width: 8, Height: 100}
+ originalColor := &props.Color{Red: 0, Green: 0, Blue: 0}
+ textProp := &props.Text{
+ Family: consts.FontFamilyArial,
+ Style: fontstyle.Normal,
+ Size: 10,
+ Align: consts.AlignLeft,
+ BreakLineStrategy: consts.BreakLineDash,
+ }
+
+ font := mocks.NewFont(t)
+ font.EXPECT().SetFont(consts.FontFamilyArial, fontstyle.Normal, 10.0)
+ font.EXPECT().GetHeight(consts.FontFamilyArial, fontstyle.Normal, 10.0).Return(5.0)
+ font.EXPECT().GetColor().Return(originalColor)
+ font.EXPECT().SetColor(originalColor)
+
+ pdf := newPDF(t)
+ // cp1252-style translator: non-ASCII runes are replaced with '.'
+ pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string {
+ var b strings.Builder
+ for _, r := range s {
+ if r < 0x80 {
+ b.WriteRune(r)
+ } else {
+ b.WriteByte('.')
+ }
+ }
+ return b.String()
+ })
+ // dash breaking of the TRANSLATED ".." into [".-", "."]
+ pdf.EXPECT().GetStringWidth(" - ").Return(2.0)
+ pdf.EXPECT().GetStringWidth(".").Return(5.0)
+ // Add: translated text does not fit → uses the cached lines
+ pdf.EXPECT().GetStringWidth("..").Return(12.0)
+ pdf.EXPECT().GetStringWidth(".-").Return(6.0)
+ pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
+ // rendered lines must contain translated glyphs, not the raw "żż"
+ pdf.EXPECT().Text(0.0, 5.0, ".-")
+ pdf.EXPECT().Text(0.0, 10.0, ".")
+
+ sut := gofpdf.NewText(pdf, mocks.NewMath(t), font)
+
+ // Act: measure first (populates the cache), then render
+ lines := sut.GetLinesQuantity("żż", textProp, 8)
+ sut.Add("żż", cell, textProp)
+
+ assert.Equal(t, 2, lines)
+ })
}
func TestText_Add(t *testing.T) {
@@ -231,8 +286,13 @@ func TestText_Add(t *testing.T) {
// Act
sut.Add("hello", cell, textProp)
})
- t.Run("when single line with justify align, should render each word at calculated position", func(t *testing.T) {
+ t.Run("when single line with justify align, should render left aligned without stretching", func(t *testing.T) {
t.Parallel()
+ // SEMANTICS CHANGE: this test previously asserted that a single justified
+ // line was stretched to the full column width. Per CSS text-align: justify
+ // semantics (and matching justifyRichTextLines), the last line of a
+ // paragraph — and therefore a single line — is start-aligned, never
+ // stretched. Only non-final wrapped lines are justified.
// Arrange
cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50}
originalColor := &props.Color{Red: 0, Green: 0, Blue: 0}
@@ -254,23 +314,137 @@ func TestText_Add(t *testing.T) {
// initial width check in Add: fits in 100
pdf.EXPECT().GetStringWidth("hello world").Return(30.0)
pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
- // addLine justify: textNotSpaces="helloworld", GetStringWidth("helloworld")=25
- // defaultSpaceWidth=GetStringWidth(" ")=3
- // spaceWidth=(100-25)/1=75
- // word "hello": Text(0, 5, "hello"), finishX=0+10=10, x=10+75=85
- // word "world": Text(85, 5, "world")
- pdf.EXPECT().GetStringWidth("helloworld").Return(25.0)
- pdf.EXPECT().GetStringWidth(" ").Return(3.0)
- pdf.EXPECT().GetStringWidth("hello").Return(10.0)
- pdf.EXPECT().GetStringWidth("world").Return(15.0)
- pdf.EXPECT().Text(0.0, 5.0, "hello")
- pdf.EXPECT().Text(85.0, 5.0, "world")
+ // single (= last) line: rendered left aligned as one string
+ pdf.EXPECT().Text(0.0, 5.0, "hello world")
sut := gofpdf.NewText(pdf, mocks.NewMath(t), font)
// Act
sut.Add("hello world", cell, textProp)
})
+ t.Run("when multiple lines with justify align, should stretch all lines except the last", func(t *testing.T) {
+ t.Parallel()
+ // Arrange
+ cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50}
+ originalColor := &props.Color{Red: 0, Green: 0, Blue: 0}
+ textProp := &props.Text{
+ Family: consts.FontFamilyArial,
+ Style: fontstyle.Normal,
+ Size: 10,
+ Align: consts.AlignJustify,
+ }
+
+ font := mocks.NewFont(t)
+ font.EXPECT().SetFont(consts.FontFamilyArial, fontstyle.Normal, 10.0)
+ font.EXPECT().GetHeight(consts.FontFamilyArial, fontstyle.Normal, 10.0).Return(5.0)
+ font.EXPECT().GetColor().Return(originalColor)
+ font.EXPECT().SetColor(originalColor)
+
+ pdf := newPDF(t)
+ pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string { return s })
+ // full text does not fit in width=100
+ pdf.EXPECT().GetStringWidth("w1 w2 w3 w4").Return(200.0)
+ // line breaking: "w1"(40) + " w2"(45) = 85 fits; " w3"(45) wraps;
+ // "w3"(40) + " w4"(45) = 85 fits → lines: ["w1 w2", "w3 w4"]
+ pdf.EXPECT().GetStringWidth("w1").Return(40.0)
+ pdf.EXPECT().GetStringWidth(" w2").Return(45.0)
+ pdf.EXPECT().GetStringWidth(" w3").Return(45.0)
+ pdf.EXPECT().GetStringWidth("w3").Return(40.0)
+ pdf.EXPECT().GetStringWidth(" w4").Return(45.0)
+ pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
+ // line 0 (justified): textNotSpaces="w1w2"(80), spaceWidth=(100-80)/1=20
+ // "w1" at x=0, finishX=40, next x=40+20=60 → "w2" at x=60
+ pdf.EXPECT().GetStringWidth("w1 w2").Return(85.0)
+ pdf.EXPECT().GetStringWidth("w1w2").Return(80.0)
+ pdf.EXPECT().GetStringWidth(" ").Return(5.0)
+ pdf.EXPECT().GetStringWidth("w2").Return(45.0)
+ pdf.EXPECT().Text(0.0, 5.0, "w1")
+ pdf.EXPECT().Text(60.0, 5.0, "w2")
+ // line 1 (last line of the paragraph): left aligned, NOT stretched
+ pdf.EXPECT().GetStringWidth("w3 w4").Return(85.0)
+ pdf.EXPECT().Text(0.0, 10.0, "w3 w4")
+
+ sut := gofpdf.NewText(pdf, mocks.NewMath(t), font)
+
+ // Act
+ sut.Add("w1 w2 w3 w4", cell, textProp)
+ })
+ t.Run("when family uses mixed case, should still apply code page translation", func(t *testing.T) {
+ t.Parallel()
+ // Regression: textToUnicode compared the family with == against the
+ // lowercase constants, so "Helvetica" skipped cp1252 translation and
+ // rendered mojibake while "helvetica" worked.
+ // Arrange
+ cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50}
+ originalColor := &props.Color{Red: 0, Green: 0, Blue: 0}
+ textProp := &props.Text{
+ Family: "Helvetica",
+ Style: fontstyle.Normal,
+ Size: 10,
+ Align: consts.AlignLeft,
+ }
+
+ font := mocks.NewFont(t)
+ font.EXPECT().SetFont("Helvetica", fontstyle.Normal, 10.0)
+ font.EXPECT().GetHeight("Helvetica", fontstyle.Normal, 10.0).Return(5.0)
+ font.EXPECT().GetColor().Return(originalColor)
+ font.EXPECT().SetColor(originalColor)
+
+ pdf := newPDF(t)
+ pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string {
+ return strings.ReplaceAll(s, "é", "\xe9")
+ })
+ pdf.EXPECT().GetStringWidth("caf\xe9").Return(20.0)
+ pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
+ // translated text must be rendered, not the raw UTF-8 input
+ pdf.EXPECT().Text(0.0, 5.0, "caf\xe9")
+
+ sut := gofpdf.NewText(pdf, mocks.NewMath(t), font)
+
+ // Act
+ sut.Add("café", cell, textProp)
+ })
+ t.Run("when clamping paddings, should not mutate the caller's prop", func(t *testing.T) {
+ t.Parallel()
+ // Regression: Add wrote the clamped Top/Left/Right back through the
+ // *props.Text pointer, so a component rendered once in a small cell kept
+ // the clamped values for every later render.
+ // Arrange
+ cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50}
+ originalColor := &props.Color{Red: 0, Green: 0, Blue: 0}
+ textProp := &props.Text{
+ Family: consts.FontFamilyArial,
+ Style: fontstyle.Normal,
+ Size: 10,
+ Align: consts.AlignLeft,
+ Top: 100, // exceeds cell.Height=50
+ Left: 150, // exceeds cell.Width=100
+ Right: 150, // exceeds cell.Width=100
+ }
+
+ font := mocks.NewFont(t)
+ font.EXPECT().SetFont(consts.FontFamilyArial, fontstyle.Normal, 10.0)
+ font.EXPECT().GetHeight(consts.FontFamilyArial, fontstyle.Normal, 10.0).Return(5.0)
+ font.EXPECT().GetColor().Return(originalColor)
+ font.EXPECT().SetColor(originalColor)
+
+ pdf := newPDF(t)
+ pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string { return s })
+ pdf.EXPECT().GetStringWidth("").Return(0.0)
+ pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
+ // clamped internally: top=50, left=100 → Text(100, 55, "")
+ pdf.EXPECT().Text(100.0, 55.0, "")
+
+ sut := gofpdf.NewText(pdf, mocks.NewMath(t), font)
+
+ // Act
+ sut.Add("", cell, textProp)
+
+ // Assert: the caller's prop keeps its original values
+ assert.Equal(t, 100.0, textProp.Top)
+ assert.Equal(t, 150.0, textProp.Left)
+ assert.Equal(t, 150.0, textProp.Right)
+ })
t.Run("when text exceeds cell width with empty space strategy, should split into multiple lines", func(t *testing.T) {
t.Parallel()
// Arrange
diff --git a/internal/providers/paper/text_unicode.go b/internal/providers/paper/text_unicode.go
new file mode 100644
index 00000000..1d706c97
--- /dev/null
+++ b/internal/providers/paper/text_unicode.go
@@ -0,0 +1,96 @@
+package paper
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/avdoseferovic/paper/pkg/consts"
+)
+
+// unsupportedGlyphsIssueKey is the render issue operation recorded when the
+// core-font code page translation replaces glyphs it cannot encode.
+const unsupportedGlyphsIssueKey = "text.unsupported-glyphs"
+
+// isCoreFontFamily reports whether family is one of the built-in core font
+// families (Arial, Helvetica, Courier, Symbol, ZapfDingbats) which expect
+// Latin-1/cp1252 codepoints. Comparison is case-insensitive because callers
+// commonly use "Helvetica" while the fontfamily constants are lowercase.
+func isCoreFontFamily(family string) bool {
+ switch strings.ToLower(family) {
+ case consts.FontFamilyArial, consts.FontFamilyHelvetica, consts.FontFamilySymbol,
+ consts.FontFamilyZapBats, consts.FontFamilyCourier:
+ return true
+ default:
+ return false
+ }
+}
+
+// translateUnicode applies the gofpdf Unicode translator for built-in font
+// families. For custom (UTF-8) fonts text passes through.
+func (s *Text) translateUnicode(text, family string) string {
+ if isCoreFontFamily(family) {
+ return s.translateDefaultCodePage(text)
+ }
+ return text
+}
+
+func (s *Text) translateDefaultCodePage(txt string) string {
+ if s.defaultCodeTranslator == nil {
+ s.defaultCodeTranslator = s.pdf.UnicodeTranslatorFromDescriptor("")
+ }
+ return s.defaultCodeTranslator(txt)
+}
+
+// SetRenderIssueSink registers fn to receive render fallback issues, such as
+// glyphs that cannot be encoded in the core-font code page.
+func (s *Text) SetRenderIssueSink(fn func(operation, message string)) {
+ s.issueSink = fn
+}
+
+// reportUnsupportedGlyphs records one render issue when the code page
+// translation replaced unmappable runes. original is the pre-translation
+// UTF-8 text; translated is the code-page byte string produced from it.
+func (s *Text) reportUnsupportedGlyphs(original, translated string) {
+ if s.issueSink == nil {
+ return
+ }
+ s.reportMissingRunes(unsupportedGlyphs(original, translated))
+}
+
+// reportMissingRunes records a single deduped render issue for the given
+// unmappable runes. No-op when the list is empty or no sink is wired.
+func (s *Text) reportMissingRunes(missing []rune) {
+ if s.issueSink == nil || len(missing) == 0 {
+ return
+ }
+ s.issueSink(unsupportedGlyphsIssueKey, fmt.Sprintf(
+ "characters %q are not supported by the built-in font code page (cp1252) and were replaced",
+ string(missing),
+ ))
+}
+
+// unsupportedGlyphs returns the distinct runes of original that the code page
+// translation replaced with the '.' fallback byte. The translator emits
+// exactly one byte per input rune, so original rune i maps to translated byte
+// i; mapped code page bytes are always >= 0x80 for non-ASCII runes, which
+// makes '.' an unambiguous fallback marker there.
+func unsupportedGlyphs(original, translated string) []rune {
+ if original == translated {
+ // Pure ASCII or no translation applied — nothing was replaced.
+ return nil
+ }
+ runes := []rune(original)
+ if len(runes) != len(translated) {
+ // Not a per-rune code page translation (translator inactive).
+ return nil
+ }
+ var missing []rune
+ seen := make(map[rune]bool)
+ for i, r := range runes {
+ if r >= 0x80 && translated[i] == '.' && !seen[r] {
+ seen[r] = true
+ missing = append(missing, r)
+ }
+ }
+ return missing
+}
diff --git a/page_geometry.go b/page_geometry.go
new file mode 100644
index 00000000..748a4fb5
--- /dev/null
+++ b/page_geometry.go
@@ -0,0 +1,64 @@
+package paper
+
+import "github.com/avdoseferovic/paper/pkg/core/entity"
+
+// SetPageGeometry configures generated PDF page dictionary geometry entries.
+func (m *Paper) SetPageGeometry(geometry entity.PageGeometry) {
+ m.config.PageGeometries = upsertPageGeometry(m.config.PageGeometries, geometry)
+}
+
+// SetPageRotation sets a generated page rotation in degrees.
+func (m *Paper) SetPageRotation(pageIndex, degrees int) {
+ geometry := m.pageGeometry(pageIndex)
+ geometry.Rotate = degrees
+ m.SetPageGeometry(geometry)
+}
+
+// SetCropBox sets a generated page CropBox.
+func (m *Paper) SetCropBox(pageIndex int, box [4]float64) {
+ geometry := m.pageGeometry(pageIndex)
+ geometry.CropBox = &box
+ m.SetPageGeometry(geometry)
+}
+
+// SetBleedBox sets a generated page BleedBox.
+func (m *Paper) SetBleedBox(pageIndex int, box [4]float64) {
+ geometry := m.pageGeometry(pageIndex)
+ geometry.BleedBox = &box
+ m.SetPageGeometry(geometry)
+}
+
+// SetTrimBox sets a generated page TrimBox.
+func (m *Paper) SetTrimBox(pageIndex int, box [4]float64) {
+ geometry := m.pageGeometry(pageIndex)
+ geometry.TrimBox = &box
+ m.SetPageGeometry(geometry)
+}
+
+// SetArtBox sets a generated page ArtBox.
+func (m *Paper) SetArtBox(pageIndex int, box [4]float64) {
+ geometry := m.pageGeometry(pageIndex)
+ geometry.ArtBox = &box
+ m.SetPageGeometry(geometry)
+}
+
+func (m *Paper) pageGeometry(pageIndex int) entity.PageGeometry {
+ for _, geometry := range m.config.PageGeometries {
+ if geometry.PageIndex == pageIndex {
+ return entity.ClonePageGeometries([]entity.PageGeometry{geometry})[0]
+ }
+ }
+ return entity.PageGeometry{PageIndex: pageIndex}
+}
+
+func upsertPageGeometry(geometries []entity.PageGeometry, geometry entity.PageGeometry) []entity.PageGeometry {
+ clones := entity.ClonePageGeometries(geometries)
+ incoming := entity.ClonePageGeometries([]entity.PageGeometry{geometry})[0]
+ for i := range clones {
+ if clones[i].PageIndex == incoming.PageIndex {
+ clones[i] = incoming
+ return clones
+ }
+ }
+ return append(clones, incoming)
+}
diff --git a/paper.go b/paper.go
index 7186045f..ac621183 100644
--- a/paper.go
+++ b/paper.go
@@ -141,5 +141,10 @@ func getProvider(cache cache.Cache, cfg *entity.Config) core.Provider {
provider.SetMetadata(cfg.Metadata)
provider.SetCompression(cfg.Compression)
provider.SetProtection(cfg.Protection)
+ if catalogProvider, ok := any(provider).(interface {
+ SetDocumentCatalog(cfg *entity.Config)
+ }); ok {
+ catalogProvider.SetDocumentCatalog(cfg)
+ }
return provider
}
diff --git a/pdfa_test.go b/pdfa_test.go
new file mode 100644
index 00000000..3d5443e2
--- /dev/null
+++ b/pdfa_test.go
@@ -0,0 +1,164 @@
+package paper_test
+
+import (
+ "bytes"
+ "context"
+ "testing"
+
+ "github.com/avdoseferovic/paper"
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/components/col"
+ "github.com/avdoseferovic/paper/pkg/components/text"
+ "github.com/avdoseferovic/paper/pkg/config"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+)
+
+func TestGenerate_WithPdfA2B_ShouldEmitMetadataAndOutputIntent(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithTitle("PDF/A Test", false).
+ WithAuthor("Paper", false).
+ WithPdfA(entity.PdfAConfig{Level: entity.PdfA2B}).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("PDF/A foundation")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+ pdfBytes := pdf.GetBytes()
+
+ assert.True(t, bytes.HasPrefix(pdfBytes, []byte("%PDF-1.7")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/Metadata")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/Subtype /XML")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("2")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("B")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/OutputIntents [")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/S /GTS_PDFA1")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("sRGB IEC61966-2.1")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("acsp")))
+}
+
+func TestGenerate_WithPdfA1B_ShouldUsePDF14(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithPdfA(entity.PdfAConfig{Level: entity.PdfA1B}).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("PDF/A-1 foundation")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+
+ assert.True(t, bytes.HasPrefix(pdf.GetBytes(), []byte("%PDF-1.4")))
+}
+
+func TestGenerate_WithPdfA2A_ShouldEnableTaggedPDF(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithPdfA(entity.PdfAConfig{Level: entity.PdfA2A}).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("Tagged PDF/A foundation")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+ pdfBytes := pdf.GetBytes()
+
+ assert.True(t, bytes.Contains(pdfBytes, []byte("A")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/StructTreeRoot")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/Marked true")))
+}
+
+func TestGenerate_WithPdfAOutputCondition(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithPdfA(entity.PdfAConfig{
+ Level: entity.PdfA3B,
+ OutputCondition: "Custom CMYK",
+ ICCProfile: []byte("profile-data"),
+ }).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("Custom output intent")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+ pdfBytes := pdf.GetBytes()
+
+ assert.True(t, bytes.Contains(pdfBytes, []byte("3")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("Custom CMYK")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("profile-data")))
+}
+
+func TestGenerate_WithPdfACustomXMPExtensions(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithPdfA(entity.PdfAConfig{
+ Level: entity.PdfA3B,
+ XMPSchemas: []entity.XMPSchema{{
+ Schema: "Factur-X PDFA Extension Schema",
+ NamespaceURI: "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#",
+ Prefix: "fx",
+ Properties: []entity.XMPSchemaProperty{{
+ Name: "DocumentFileName",
+ ValueType: "Text",
+ Category: "external",
+ Description: "Name of the embedded XML invoice file",
+ }},
+ }},
+ XMPProperties: []entity.XMPPropertyBlock{{
+ Namespace: "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#",
+ Prefix: "fx",
+ Properties: []entity.XMPProperty{
+ {Name: "DocumentFileName", Value: "factur-x.xml"},
+ {Name: "DocumentType", Value: "INVOICE"},
+ {Name: "ConformanceLevel", Value: "BASIC & WL"},
+ },
+ }},
+ }).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("Factur-X PDF/A metadata")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+ pdfBytes := pdf.GetBytes()
+
+ assert.True(t, bytes.Contains(pdfBytes, []byte(`xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"`)))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("PDF/A Associated File Attachment")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("Factur-X PDFA Extension Schema")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("fx")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("DocumentFileName")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte(``)))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("factur-x.xml")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("INVOICE")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("BASIC & WL")))
+}
+
+func TestGenerate_WithRuntimeSetPdfAAndConcurrentMode_ShouldKeepCatalogEntries(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithConcurrentMode(2).
+ Build()
+ doc := paper.New(cfg)
+ doc.SetPdfA(entity.PdfAConfig{Level: entity.PdfA2B})
+ doc.AddAutoRow(col.New(12).Add(text.New("Runtime PDF/A foundation")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+
+ assert.True(t, bytes.Contains(pdf.GetBytes(), []byte("/OutputIntents [")))
+}
diff --git a/pkg/barcode/barcode.go b/pkg/barcode/barcode.go
new file mode 100644
index 00000000..55c0aba5
--- /dev/null
+++ b/pkg/barcode/barcode.go
@@ -0,0 +1,90 @@
+// Package barcode provides named barcode constructors over Paper's component
+// model.
+package barcode
+
+import (
+ codecomp "github.com/avdoseferovic/paper/pkg/components/code"
+ "github.com/avdoseferovic/paper/pkg/consts"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+type Type = consts.BarcodeType
+
+const (
+ Code128 Type = consts.BarcodeCode128
+ EAN13 Type = consts.BarcodeEAN
+)
+
+func NewCode128(value string, barcodeProps ...props.Barcode) core.Component {
+ return codecomp.NewBar(value, withType(Code128, barcodeProps)...)
+}
+
+func NewCode128Col(size int, value string, barcodeProps ...props.Barcode) core.Col {
+ return codecomp.NewBarCol(size, value, withType(Code128, barcodeProps)...)
+}
+
+func NewCode128Row(height float64, value string, barcodeProps ...props.Barcode) core.Row {
+ return codecomp.NewBarRow(height, value, withType(Code128, barcodeProps)...)
+}
+
+func NewAutoCode128Row(value string, barcodeProps ...props.Barcode) core.Row {
+ return codecomp.NewAutoBarRow(value, withType(Code128, barcodeProps)...)
+}
+
+func NewEAN13(value string, barcodeProps ...props.Barcode) core.Component {
+ return codecomp.NewBar(value, withType(EAN13, barcodeProps)...)
+}
+
+func NewEAN13Col(size int, value string, barcodeProps ...props.Barcode) core.Col {
+ return codecomp.NewBarCol(size, value, withType(EAN13, barcodeProps)...)
+}
+
+func NewEAN13Row(height float64, value string, barcodeProps ...props.Barcode) core.Row {
+ return codecomp.NewBarRow(height, value, withType(EAN13, barcodeProps)...)
+}
+
+func NewAutoEAN13Row(value string, barcodeProps ...props.Barcode) core.Row {
+ return codecomp.NewAutoBarRow(value, withType(EAN13, barcodeProps)...)
+}
+
+func NewQR(value string, rectProps ...props.Rect) core.Component {
+ return codecomp.NewQr(value, rectProps...)
+}
+
+func NewQRCol(size int, value string, rectProps ...props.Rect) core.Col {
+ return codecomp.NewQrCol(size, value, rectProps...)
+}
+
+func NewQRRow(height float64, value string, rectProps ...props.Rect) core.Row {
+ return codecomp.NewQrRow(height, value, rectProps...)
+}
+
+func NewAutoQRRow(value string, rectProps ...props.Rect) core.Row {
+ return codecomp.NewAutoQrRow(value, rectProps...)
+}
+
+func NewDataMatrix(value string, rectProps ...props.Rect) core.Component {
+ return codecomp.NewMatrix(value, rectProps...)
+}
+
+func NewDataMatrixCol(size int, value string, rectProps ...props.Rect) core.Col {
+ return codecomp.NewMatrixCol(size, value, rectProps...)
+}
+
+func NewDataMatrixRow(height float64, value string, rectProps ...props.Rect) core.Row {
+ return codecomp.NewMatrixRow(height, value, rectProps...)
+}
+
+func NewAutoDataMatrixRow(value string, rectProps ...props.Rect) core.Row {
+ return codecomp.NewAutoMatrixRow(value, rectProps...)
+}
+
+func withType(kind Type, barcodeProps []props.Barcode) []props.Barcode {
+ prop := props.Barcode{}
+ if len(barcodeProps) > 0 {
+ prop = barcodeProps[0]
+ }
+ prop.Type = kind
+ return []props.Barcode{prop}
+}
diff --git a/pkg/barcode/barcode_test.go b/pkg/barcode/barcode_test.go
new file mode 100644
index 00000000..51c9ddb9
--- /dev/null
+++ b/pkg/barcode/barcode_test.go
@@ -0,0 +1,73 @@
+package barcode_test
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/fixture"
+ "github.com/avdoseferovic/paper/internal/mocks"
+ mock "github.com/avdoseferovic/paper/internal/mocktest"
+ "github.com/avdoseferovic/paper/pkg/barcode"
+ "github.com/avdoseferovic/paper/pkg/consts"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+func TestNewCode128(t *testing.T) {
+ t.Parallel()
+
+ cell := fixture.CellEntity()
+ component := barcode.NewCode128("ABC123")
+
+ provider := mocks.NewProvider(t)
+ provider.EXPECT().AddBarCode("ABC123", &cell, propsWithType(t, consts.BarcodeCode128)).Once()
+
+ component.Render(provider, &cell)
+}
+
+func TestNewEAN13(t *testing.T) {
+ t.Parallel()
+
+ cell := fixture.CellEntity()
+ component := barcode.NewEAN13("123456789123")
+
+ provider := mocks.NewProvider(t)
+ provider.EXPECT().AddBarCode("123456789123", &cell, propsWithType(t, consts.BarcodeEAN)).Once()
+
+ component.Render(provider, &cell)
+}
+
+func TestNewQR(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "qrcode", barcode.NewQR("payload").GetStructure().GetData().Type)
+}
+
+func TestNewDataMatrix(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "matrixcode", barcode.NewDataMatrix("payload").GetStructure().GetData().Type)
+}
+
+func TestRowAndColConstructors(t *testing.T) {
+ t.Parallel()
+
+ assert.NotNil(t, barcode.NewCode128Col(6, "ABC123"))
+ assert.NotNil(t, barcode.NewCode128Row(12, "ABC123"))
+ assert.NotNil(t, barcode.NewAutoCode128Row("ABC123"))
+ assert.NotNil(t, barcode.NewEAN13Col(6, "123456789123"))
+ assert.NotNil(t, barcode.NewEAN13Row(12, "123456789123"))
+ assert.NotNil(t, barcode.NewAutoEAN13Row("123456789123"))
+ assert.NotNil(t, barcode.NewQRCol(6, "payload"))
+ assert.NotNil(t, barcode.NewQRRow(12, "payload"))
+ assert.NotNil(t, barcode.NewAutoQRRow("payload"))
+ assert.NotNil(t, barcode.NewDataMatrixCol(6, "payload"))
+ assert.NotNil(t, barcode.NewDataMatrixRow(12, "payload"))
+ assert.NotNil(t, barcode.NewAutoDataMatrixRow("payload"))
+}
+
+func propsWithType(t *testing.T, want consts.BarcodeType) interface{} {
+ t.Helper()
+ return mock.MatchedBy(func(prop *props.Barcode) bool {
+ return prop != nil && prop.Type == want
+ })
+}
diff --git a/pkg/components/htmllist/htmllist.go b/pkg/components/htmllist/htmllist.go
index fee11326..be7764b0 100644
--- a/pkg/components/htmllist/htmllist.go
+++ b/pkg/components/htmllist/htmllist.go
@@ -25,8 +25,11 @@ const (
// Prop holds list-level configuration.
type Prop struct {
- Style StyleType
- Start int // 1-based first marker value; 0 = default
+ Style StyleType
+ Start int // first marker value; 0 = default unless StartSet
+ // StartSet distinguishes an explicit start="0" (or negative start) from
+ // the attribute being absent.
+ StartSet bool
Reversed bool // ordered markers count down from Start or item count
Indent float64 // mm per nesting level
MarkerPadding float64 // mm gap between marker and content
@@ -44,6 +47,20 @@ type Prop struct {
type Item struct {
Content core.Component
SubList *HTMLList
+ // Marker optionally overrides how this item's marker renders (from CSS
+ // ::marker rules). nil keeps the list-level default.
+ Marker *ItemMarker
+}
+
+// ItemMarker overrides a single item's marker rendering.
+type ItemMarker struct {
+ // Text replaces the style-derived label (e.g. ::marker content). A custom
+ // text renders even when the list style is None.
+ Text string
+ // Suppress removes the marker entirely (::marker content: none).
+ Suppress bool
+ // TextProp carries per-item text overrides; zero fields keep defaults.
+ TextProp *props.Text
}
// HTMLList is a core.Component rendering bullet/numbered lists.
@@ -156,11 +173,11 @@ func (l *HTMLList) Render(provider core.Provider, cell *entity.Cell) {
}
}
- marker := FormatMarker(l.prop.Style, l.markerIndex(i))
+ marker, show := l.itemMarkerLabel(i)
// Marker is anchored to the first line of the item, not stretched to itemH.
markerCell := &entity.Cell{X: cell.X, Y: y, Width: gutter, Height: lineH}
- if l.prop.Style != None {
- l.renderMarker(provider, marker, markerCell)
+ if show {
+ l.renderMarker(provider, marker, markerCell, l.items[i].Marker)
}
if item.Content != nil {
@@ -183,17 +200,44 @@ func (l *HTMLList) Render(provider core.Provider, cell *entity.Cell) {
}
}
+// MarkerLabel returns the formatted marker text for a zero-based item index,
+// honoring Start/StartSet/Reversed. ok is false for out-of-range indexes.
+func (l *HTMLList) MarkerLabel(i int) (string, bool) {
+ if l == nil || i < 0 || i >= len(l.items) {
+ return "", false
+ }
+ return l.itemMarkerLabel(i)
+}
+
+// itemMarkerLabel resolves the marker label for an item, honouring per-item
+// ::marker overrides: suppressed markers render nothing, custom content
+// renders even when the list style is None.
+func (l *HTMLList) itemMarkerLabel(i int) (string, bool) {
+ if m := l.items[i].Marker; m != nil {
+ if m.Suppress {
+ return "", false
+ }
+ if m.Text != "" {
+ return m.Text, true
+ }
+ }
+ if l.prop.Style == None {
+ return "", false
+ }
+ return FormatMarker(l.prop.Style, l.markerIndex(i)), true
+}
+
// renderMarker draws a single list marker — either as text (default) or as a
// filled circle with the index centred inside (DecimalCircle style).
-func (l *HTMLList) renderMarker(provider core.Provider, label string, cell *entity.Cell) {
+func (l *HTMLList) renderMarker(provider core.Provider, label string, cell *entity.Cell, override *ItemMarker) {
if l.prop.Style != DecimalCircle {
- provider.AddText(label, cell, l.markerTextProp())
+ provider.AddText(label, cell, l.itemMarkerTextProp(override))
return
}
// Circle marker: best-effort via ShapeProvider; fallback to text-only.
sp, ok := provider.(core.ShapeProvider)
if !ok {
- provider.AddText(label, cell, l.markerTextProp())
+ provider.AddText(label, cell, l.itemMarkerTextProp(override))
return
}
// Draw the circle slightly larger than the text line box. The marker cell's
@@ -243,19 +287,21 @@ func (l *HTMLList) renderMarker(provider core.Provider, label string, cell *enti
// so the inscribed circle is readable (otherwise a 2-char "10" measurement
// produces a circle smaller than the digit it contains).
func (l *HTMLList) gutterWidth(provider core.Provider) float64 {
- if l.prop.Style == None {
+ if !l.anyMarkerShows() {
return 0
}
if l.prop.GutterWidth > 0 {
return l.prop.GutterWidth
}
- tp := l.markerTextProp()
lineH := l.itemRowHeight(provider, &entity.Cell{Width: 100})
textWidth := 0.0
if rtp, ok := provider.(core.RichTextProvider); ok {
for i := range len(l.items) {
- m := FormatMarker(l.prop.Style, l.markerIndex(i))
- w := rtp.MeasureString(m, tp)
+ m, show := l.itemMarkerLabel(i)
+ if !show {
+ continue
+ }
+ w := rtp.MeasureString(m, l.itemMarkerTextProp(l.items[i].Marker))
if w > textWidth {
textWidth = w
}
@@ -290,6 +336,40 @@ func (l *HTMLList) markerTextProp() *props.Text {
return tp
}
+// anyMarkerShows reports whether at least one item renders a marker, so the
+// gutter is reserved. Style None normally means no gutter, but a per-item
+// ::marker content override still renders.
+func (l *HTMLList) anyMarkerShows() bool {
+ for i := range l.items {
+ if _, show := l.itemMarkerLabel(i); show {
+ return true
+ }
+ }
+ return false
+}
+
+// itemMarkerTextProp merges an item's ::marker overrides onto the list-level
+// marker text prop. nil override keeps the default.
+func (l *HTMLList) itemMarkerTextProp(override *ItemMarker) *props.Text {
+ tp := l.markerTextProp()
+ if override == nil || override.TextProp == nil {
+ return tp
+ }
+ if override.TextProp.Color != nil {
+ tp.Color = props.CloneColor(override.TextProp.Color)
+ }
+ if override.TextProp.Size > 0 {
+ tp.Size = override.TextProp.Size
+ }
+ if override.TextProp.Family != "" {
+ tp.Family = override.TextProp.Family
+ }
+ if override.TextProp.Style != "" {
+ tp.Style = override.TextProp.Style
+ }
+ return tp
+}
+
func (l *HTMLList) circleMarkerTextProp() *props.Text {
tp := l.markerTextProp()
tp.Style = fontstyle.Bold
@@ -302,14 +382,14 @@ func (l *HTMLList) circleMarkerTextProp() *props.Text {
func (l *HTMLList) markerIndex(itemIndex int) int {
start := l.prop.Start
- if start == 0 {
+ if start == 0 && !l.prop.StartSet {
start = 1
if l.prop.Reversed {
start = len(l.items)
}
}
if l.prop.Reversed {
- return max(start-itemIndex, 1) - 1
+ return start - itemIndex - 1
}
- return max(start+itemIndex, 1) - 1
+ return start + itemIndex - 1
}
diff --git a/pkg/components/htmllist/marker.go b/pkg/components/htmllist/marker.go
index 1b80f197..78e3e865 100644
--- a/pkg/components/htmllist/marker.go
+++ b/pkg/components/htmllist/marker.go
@@ -18,12 +18,24 @@ func FormatMarker(style StyleType, idx int) string {
// Circle markers render the bare number (no trailing period) centred in a disc.
return strconv.Itoa(idx + 1)
case LowerAlpha:
+ if idx < 0 {
+ return strconv.Itoa(idx+1) + "." // alphabetic counters have no zero/negative form
+ }
return toAlpha(idx, false) + "."
case UpperAlpha:
+ if idx < 0 {
+ return strconv.Itoa(idx+1) + "."
+ }
return toAlpha(idx, true) + "."
case LowerRoman:
+ if idx < 0 {
+ return strconv.Itoa(idx+1) + "." // roman numerals have no zero/negative form
+ }
return toRoman(idx+1, false) + "."
case UpperRoman:
+ if idx < 0 {
+ return strconv.Itoa(idx+1) + "."
+ }
return toRoman(idx+1, true) + "."
default: // Unknown styles render as bullets.
return "•"
diff --git a/pkg/components/page/page.go b/pkg/components/page/page.go
index db9d8279..52b1104a 100644
--- a/pkg/components/page/page.go
+++ b/pkg/components/page/page.go
@@ -2,6 +2,8 @@
package page
import (
+ "sort"
+
"github.com/avdoseferovic/paper/pkg/tree/node"
"github.com/avdoseferovic/paper/pkg/core"
@@ -10,13 +12,15 @@ import (
)
type Page struct {
- number int
- total int
- index int
- rows []core.Row
- config *entity.Config
- prop props.PageNumber
- control core.PageControl
+ number int
+ total int
+ index int
+ pageTotal int
+ runningStrings map[string]string
+ rows []core.Row
+ config *entity.Config
+ prop props.PageNumber
+ control core.PageControl
}
// New is responsible to create a core.Page.
@@ -63,9 +67,12 @@ func (p *Page) Render(provider core.Provider, cell entity.Cell) {
}
}
- for _, row := range p.rows {
- row.Render(provider, innerCell)
- innerCell.Y += row.GetHeight(provider, &innerCell)
+ if pcp, ok := provider.(core.PageContextProvider); ok {
+ pcp.WithPageContextStrings(p.index, p.pageTotal, p.runningStrings, func() {
+ p.renderRows(provider, innerCell)
+ })
+ } else {
+ p.renderRows(provider, innerCell)
}
if p.isFirstPage() {
@@ -161,6 +168,18 @@ func (p *Page) SetPageIndex(index int) {
p.index = index
}
+// SetPageTotal records the physical page total exposed via the provider's
+// page context while this page renders.
+func (p *Page) SetPageTotal(total int) {
+ p.pageTotal = total
+}
+
+// SetRunningStrings records named running strings (e.g. the active chapter
+// title) exposed via the provider's page context while this page renders.
+func (p *Page) SetRunningStrings(runningStrings map[string]string) {
+ p.runningStrings = runningStrings
+}
+
func (p *Page) SetPageControl(control core.PageControl) {
p.control = control
}
@@ -218,3 +237,31 @@ func (p *Page) contentCell(cell entity.Cell) entity.Cell {
func (p *Page) isFirstPage() bool {
return p.index == 1 || (p.index == 0 && p.number == 1)
}
+
+// renderRows renders the page's rows in paint-layer order: rows whose
+// RenderLayer is negative paint first (behind the flow), flow rows (no layer
+// or layer 0) next, and positive layers last (in front). Every row keeps the
+// Y position it would have in plain flow order — layered rows typically
+// report zero height, so they don't consume flow space.
+func (p *Page) renderRows(provider core.Provider, innerCell entity.Cell) {
+ type placedRow struct {
+ row core.Row
+ cell entity.Cell
+ layer int
+ }
+ placed := make([]placedRow, 0, len(p.rows))
+ for _, row := range p.rows {
+ pr := placedRow{row: row, cell: innerCell}
+ if lr, ok := row.(core.LayeredRow); ok {
+ pr.layer = lr.RenderLayer()
+ }
+ placed = append(placed, pr)
+ innerCell.Y += row.GetHeight(provider, &innerCell)
+ }
+ sort.SliceStable(placed, func(i, j int) bool {
+ return placed[i].layer < placed[j].layer
+ })
+ for _, pr := range placed {
+ pr.row.Render(provider, pr.cell)
+ }
+}
diff --git a/pkg/components/page/page_layers_test.go b/pkg/components/page/page_layers_test.go
new file mode 100644
index 00000000..4c251f3b
--- /dev/null
+++ b/pkg/components/page/page_layers_test.go
@@ -0,0 +1,204 @@
+package page_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/pkg/components/page"
+ "github.com/avdoseferovic/paper/pkg/consts/extension"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/props"
+ "github.com/avdoseferovic/paper/pkg/tree/node"
+)
+
+type renderLayerTestRow struct {
+ name string
+ height float64
+ layer int
+ hasLayer bool
+ renderLog *[]string
+}
+
+func (r *renderLayerTestRow) SetConfig(*entity.Config) {}
+
+func (r *renderLayerTestRow) GetStructure() *node.Node[core.Structure] {
+ return node.New(core.Structure{Type: r.name})
+}
+
+func (r *renderLayerTestRow) Add(...core.Col) core.Row { return r }
+
+func (r *renderLayerTestRow) GetHeight(core.Provider, *entity.Cell) float64 {
+ return r.height
+}
+
+func (r *renderLayerTestRow) GetColumns() []core.Col { return nil }
+
+func (r *renderLayerTestRow) WithStyle(*props.Cell) core.Row { return r }
+
+func (r *renderLayerTestRow) Render(_ core.Provider, cell entity.Cell) {
+ *r.renderLog = append(*r.renderLog, fmt.Sprintf("%s@%.0f", r.name, cell.Y))
+}
+
+func (r *renderLayerTestRow) RenderLayer() int {
+ if !r.hasLayer {
+ return 0
+ }
+ return r.layer
+}
+
+func TestPage_RenderOrdersLayeredRowsAroundFlow(t *testing.T) {
+ t.Parallel()
+
+ var renderLog []string
+ sut := page.New()
+ sut.Add(
+ &renderLayerTestRow{name: "front", layer: 1, hasLayer: true, renderLog: &renderLog},
+ &renderLayerTestRow{name: "flow-a", height: 7, renderLog: &renderLog},
+ &renderLayerTestRow{name: "behind", layer: -1, hasLayer: true, renderLog: &renderLog},
+ &renderLayerTestRow{name: "flow-b", height: 5, renderLog: &renderLog},
+ )
+ sut.SetConfig(&entity.Config{})
+
+ sut.Render(nil, entity.Cell{Width: 100, Height: 100})
+
+ assert.Equal(t, []string{"behind@7", "flow-a@0", "flow-b@7", "front@0"}, renderLog)
+}
+
+type pageContextProviderStub struct {
+ current int
+ total int
+ runningStrings map[string]string
+}
+
+func (p *pageContextProviderStub) WithPageContext(current, total int, fn func()) {
+ p.WithPageContextStrings(current, total, nil, fn)
+}
+
+func (p *pageContextProviderStub) WithPageContextStrings(current, total int, runningStrings map[string]string, fn func()) {
+ prevCurrent, prevTotal := p.current, p.total
+ prevRunningStrings := p.runningStrings
+ p.current, p.total = current, total
+ p.runningStrings = clonePageContextStrings(runningStrings)
+ defer func() {
+ p.current, p.total = prevCurrent, prevTotal
+ p.runningStrings = prevRunningStrings
+ }()
+ fn()
+}
+
+func clonePageContextStrings(runningStrings map[string]string) map[string]string {
+ if len(runningStrings) == 0 {
+ return nil
+ }
+ clone := make(map[string]string, len(runningStrings))
+ for name, value := range runningStrings {
+ clone[name] = value
+ }
+ return clone
+}
+
+func (p *pageContextProviderStub) CreateRow(float64) {}
+func (p *pageContextProviderStub) CreateCol(float64, float64, *entity.Config, *props.Cell) {
+}
+func (p *pageContextProviderStub) AddLine(*entity.Cell, *props.Line) {}
+func (p *pageContextProviderStub) AddText(string, *entity.Cell, *props.Text) {
+}
+func (p *pageContextProviderStub) AddCheckbox(string, *entity.Cell, *props.Checkbox) {
+}
+func (p *pageContextProviderStub) GetFontHeight(*props.Font) float64 { return 1 }
+func (p *pageContextProviderStub) GetLinesQuantity(string, *props.Text, float64) int {
+ return 1
+}
+func (p *pageContextProviderStub) AddMatrixCode(string, *entity.Cell, *props.Rect) {
+}
+func (p *pageContextProviderStub) AddQrCode(string, *entity.Cell, *props.Rect) {}
+func (p *pageContextProviderStub) AddBarCode(string, *entity.Cell, *props.Barcode) {
+}
+func (p *pageContextProviderStub) GetDimensionsByMatrixCode(string) (*entity.Dimensions, error) {
+ return nil, nil
+}
+func (p *pageContextProviderStub) GetDimensionsByQrCode(string) (*entity.Dimensions, error) {
+ return nil, nil
+}
+func (p *pageContextProviderStub) GetDimensionsByImageByte([]byte, extension.Type) (*entity.Dimensions, error) {
+ return nil, nil
+}
+func (p *pageContextProviderStub) GetDimensionsByImage(string) (*entity.Dimensions, error) {
+ return nil, nil
+}
+func (p *pageContextProviderStub) AddImageFromFile(string, *entity.Cell, *props.Rect) {
+}
+func (p *pageContextProviderStub) AddImageFromBytes([]byte, *entity.Cell, *props.Rect, extension.Type) {
+}
+func (p *pageContextProviderStub) AddBackgroundImageFromBytes([]byte, *entity.Cell, *props.Rect, extension.Type) {
+}
+func (p *pageContextProviderStub) GenerateBytes() ([]byte, error) { return nil, nil }
+func (p *pageContextProviderStub) SetProtection(*entity.Protection) {}
+func (p *pageContextProviderStub) SetCompression(bool) {}
+func (p *pageContextProviderStub) SetMetadata(*entity.Metadata) {}
+
+type pageContextRecordingRow struct {
+ renderLog *[]string
+ stringName string
+}
+
+func (r *pageContextRecordingRow) SetConfig(*entity.Config) {}
+func (r *pageContextRecordingRow) GetStructure() *node.Node[core.Structure] {
+ return node.New(core.Structure{Type: "page-context"})
+}
+func (r *pageContextRecordingRow) Add(...core.Col) core.Row { return r }
+func (r *pageContextRecordingRow) GetHeight(core.Provider, *entity.Cell) float64 {
+ return 1
+}
+func (r *pageContextRecordingRow) GetColumns() []core.Col { return nil }
+func (r *pageContextRecordingRow) WithStyle(*props.Cell) core.Row {
+ return r
+}
+func (r *pageContextRecordingRow) Render(provider core.Provider, _ entity.Cell) {
+ ctx := provider.(*pageContextProviderStub)
+ entry := fmt.Sprintf("%d/%d", ctx.current, ctx.total)
+ if r.stringName != "" {
+ entry += ":" + ctx.runningStrings[r.stringName]
+ }
+ *r.renderLog = append(*r.renderLog, entry)
+}
+
+func TestPage_RenderProvidesPhysicalPageContext(t *testing.T) {
+ t.Parallel()
+
+ var renderLog []string
+ sut := page.New()
+ sut.Add(&pageContextRecordingRow{renderLog: &renderLog})
+ sut.SetConfig(&entity.Config{})
+ sut.(interface{ SetPageIndex(int) }).SetPageIndex(2)
+ sut.(interface{ SetPageTotal(int) }).SetPageTotal(5)
+
+ provider := &pageContextProviderStub{}
+ sut.Render(provider, entity.Cell{Width: 100, Height: 100})
+
+ assert.Equal(t, []string{"2/5"}, renderLog)
+ assert.Equal(t, 0, provider.current)
+ assert.Equal(t, 0, provider.total)
+}
+
+func TestPage_RenderProvidesRunningStringContext(t *testing.T) {
+ t.Parallel()
+
+ var renderLog []string
+ sut := page.New()
+ sut.Add(&pageContextRecordingRow{renderLog: &renderLog, stringName: "chapter"})
+ sut.SetConfig(&entity.Config{})
+ sut.(interface{ SetPageIndex(int) }).SetPageIndex(3)
+ sut.(interface{ SetPageTotal(int) }).SetPageTotal(7)
+ sut.(interface{ SetRunningStrings(map[string]string) }).SetRunningStrings(map[string]string{"chapter": "One"})
+
+ provider := &pageContextProviderStub{}
+ sut.Render(provider, entity.Cell{Width: 100, Height: 100})
+
+ assert.Equal(t, []string{"3/7:One"}, renderLog)
+ assert.Equal(t, 0, provider.current)
+ assert.Equal(t, 0, provider.total)
+ assert.Nil(t, provider.runningStrings)
+}
diff --git a/pkg/components/table/table.go b/pkg/components/table/table.go
index edd33374..2aa8e946 100644
--- a/pkg/components/table/table.go
+++ b/pkg/components/table/table.go
@@ -44,6 +44,7 @@ type Table struct {
align string
borderSpacingX float64
borderSpacingY float64
+ borderCollapse bool
}
// New validates spans, normalises the grid, and builds the Table component.
diff --git a/pkg/components/table/table_border.go b/pkg/components/table/table_border.go
new file mode 100644
index 00000000..f1d32ae1
--- /dev/null
+++ b/pkg/components/table/table_border.go
@@ -0,0 +1,74 @@
+package table
+
+import (
+ "github.com/avdoseferovic/paper/pkg/consts/border"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+const defaultCollapsedBorderThickness = 0.2
+
+func (t *Table) collapsedCellStyle(cell *Cell, row, col int) *props.Cell {
+ if t == nil || cell == nil || cell.Style == nil || !t.borderCollapse {
+ if cell == nil {
+ return nil
+ }
+ return cell.Style
+ }
+ style := props.CloneCell(cell.Style)
+ expandLegacyBorders(style)
+ clearBorderRadius(style)
+ if col+max(cell.Colspan, 1) < t.colCount {
+ style.BorderRightThickness = 0
+ style.BorderRightColor = nil
+ style.BorderRightStyle = ""
+ }
+ if row+max(cell.Rowspan, 1) < t.rowCount {
+ style.BorderBottomThickness = 0
+ style.BorderBottomColor = nil
+ style.BorderBottomStyle = ""
+ }
+ return style
+}
+
+func expandLegacyBorders(style *props.Cell) {
+ if style == nil || style.BorderType == border.None {
+ return
+ }
+ thickness := style.BorderThickness
+ if thickness <= 0 {
+ thickness = defaultCollapsedBorderThickness
+ }
+ if style.BorderType.HasTop() && style.BorderTopThickness == 0 {
+ style.BorderTopThickness = thickness
+ style.BorderTopColor = props.CloneColor(style.BorderColor)
+ style.BorderTopStyle = style.LineStyle
+ }
+ if style.BorderType.HasRight() && style.BorderRightThickness == 0 {
+ style.BorderRightThickness = thickness
+ style.BorderRightColor = props.CloneColor(style.BorderColor)
+ style.BorderRightStyle = style.LineStyle
+ }
+ if style.BorderType.HasBottom() && style.BorderBottomThickness == 0 {
+ style.BorderBottomThickness = thickness
+ style.BorderBottomColor = props.CloneColor(style.BorderColor)
+ style.BorderBottomStyle = style.LineStyle
+ }
+ if style.BorderType.HasLeft() && style.BorderLeftThickness == 0 {
+ style.BorderLeftThickness = thickness
+ style.BorderLeftColor = props.CloneColor(style.BorderColor)
+ style.BorderLeftStyle = style.LineStyle
+ }
+ style.BorderType = border.None
+ style.BorderThickness = 0
+}
+
+func clearBorderRadius(style *props.Cell) {
+ if style == nil {
+ return
+ }
+ style.BorderRadius = 0
+ style.BorderRadiusTopLeft = 0
+ style.BorderRadiusTopRight = 0
+ style.BorderRadiusBottomRight = 0
+ style.BorderRadiusBottomLeft = 0
+}
diff --git a/pkg/components/table/table_collapse_internal_test.go b/pkg/components/table/table_collapse_internal_test.go
new file mode 100644
index 00000000..72efcd63
--- /dev/null
+++ b/pkg/components/table/table_collapse_internal_test.go
@@ -0,0 +1,46 @@
+package table
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/consts/border"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+func TestWithBorderCollapseMergesInteriorEdges(t *testing.T) {
+ t.Parallel()
+
+ style := &props.Cell{BorderType: border.Full, BorderThickness: 0.5}
+ cells := [][]Cell{
+ {{Style: style}, {Style: style}},
+ {{Style: style}, {Style: style}},
+ }
+ tbl, err := New(cells, WithBorderCollapse(true))
+ require.NoError(t, err)
+
+ topLeft := tbl.collapsedCellStyle(&cells[0][0], 0, 0)
+ require.NotNil(t, topLeft)
+ assert.Equal(t, 0.0, topLeft.BorderRightThickness, "interior right edge collapses")
+ assert.Equal(t, 0.0, topLeft.BorderBottomThickness, "interior bottom edge collapses")
+ assert.True(t, topLeft.BorderTopThickness > 0, "exterior top edge stays")
+ assert.True(t, topLeft.BorderLeftThickness > 0, "exterior left edge stays")
+
+ bottomRight := tbl.collapsedCellStyle(&cells[1][1], 1, 1)
+ require.NotNil(t, bottomRight)
+ assert.True(t, bottomRight.BorderRightThickness > 0, "exterior right edge stays")
+ assert.True(t, bottomRight.BorderBottomThickness > 0, "exterior bottom edge stays")
+}
+
+func TestCollapsedCellStyleDisabledKeepsOriginal(t *testing.T) {
+ t.Parallel()
+
+ style := &props.Cell{BorderType: border.Full, BorderThickness: 0.5}
+ cells := [][]Cell{{{Style: style}}}
+ tbl, err := New(cells)
+ require.NoError(t, err)
+
+ got := tbl.collapsedCellStyle(&cells[0][0], 0, 0)
+ assert.Equal(t, style, got, "without border-collapse the original style is used as-is")
+}
diff --git a/pkg/components/table/table_columns.go b/pkg/components/table/table_columns.go
index 6b03f803..fdad1e26 100644
--- a/pkg/components/table/table_columns.go
+++ b/pkg/components/table/table_columns.go
@@ -39,6 +39,15 @@ func WithAlign(align string) Option {
}
}
+// WithBorderCollapse merges adjacent cell borders, matching CSS
+// border-collapse: collapse. Interior cell edges are drawn once instead of
+// twice; border-spacing is ignored by CSS in this mode.
+func WithBorderCollapse(collapse bool) Option {
+ return func(t *Table) {
+ t.borderCollapse = collapse
+ }
+}
+
// WithBorderSpacing reserves horizontal and vertical spacing between table
// cells, matching CSS border-spacing for separate-border tables.
func WithBorderSpacing(x, y float64) Option {
diff --git a/pkg/components/table/table_render.go b/pkg/components/table/table_render.go
index 5651d517..7db40e59 100644
--- a/pkg/components/table/table_render.go
+++ b/pkg/components/table/table_render.go
@@ -35,14 +35,15 @@ func (t *Table) Render(provider core.Provider, cell *entity.Cell) {
continue
}
w := t.columnSpanWidth(tableCell.Width, c, declCell.Colspan)
+ cellStyle := t.collapsedCellStyle(declCell, r, c)
outerCell := explicitCellBox(x, y, w, rowH, declCell)
- innerCell := paddedTableCell(outerCell.X, outerCell.Y, outerCell.Width, outerCell.Height, declCell.Style)
- if declCell.Style != nil {
- paintCell := layout.ApplyCellMargins(outerCell, declCell.Style)
+ innerCell := paddedTableCell(outerCell.X, outerCell.Y, outerCell.Width, outerCell.Height, cellStyle)
+ if cellStyle != nil {
+ paintCell := layout.ApplyCellMargins(outerCell, cellStyle)
if pp, ok := provider.(core.PositionProvider); ok {
pp.SetCursor(paintCell.X, paintCell.Y)
}
- provider.CreateCol(paintCell.Width, paintCell.Height, t.config, declCell.Style)
+ provider.CreateCol(paintCell.Width, paintCell.Height, t.config, cellStyle)
}
if declCell.Content != nil {
declCell.Content.Render(provider, &innerCell)
diff --git a/pkg/config/acroform_test.go b/pkg/config/acroform_test.go
new file mode 100644
index 00000000..11161bac
--- /dev/null
+++ b/pkg/config/acroform_test.go
@@ -0,0 +1,41 @@
+package config_test
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/config"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/forms"
+)
+
+func TestBuilder_WithAcroFormClonesInput(t *testing.T) {
+ t.Parallel()
+
+ field := forms.NewTextField("name", [4]float64{1, 2, 3, 4}, 0).SetValue("Ada")
+ form := forms.NewAcroForm().Add(field)
+
+ cfg := config.NewBuilder().WithAcroForm(form).Build()
+ field.Name = "mutated"
+ field.Value = "Grace"
+
+ require.NotNil(t, cfg.AcroForm)
+ require.Len(t, cfg.AcroForm.Fields(), 1)
+ assert.Equal(t, "name", cfg.AcroForm.Fields()[0].Name)
+ assert.Equal(t, "Ada", cfg.AcroForm.Fields()[0].Value)
+}
+
+func TestNormalizeConfig_ClonesAcroForm(t *testing.T) {
+ t.Parallel()
+
+ field := forms.NewDropdown("country", [4]float64{1, 2, 3, 4}, 0, []string{"USA", "Canada"})
+ input := &entity.Config{AcroForm: forms.NewAcroForm().Add(field)}
+
+ normalized := config.NormalizeConfig(input)
+ field.Options[0] = "mutated"
+
+ require.NotNil(t, normalized.AcroForm)
+ require.Len(t, normalized.AcroForm.Fields(), 1)
+ assert.Equal(t, []string{"USA", "Canada"}, normalized.AcroForm.Fields()[0].Options)
+}
diff --git a/pkg/config/builder.go b/pkg/config/builder.go
index d06ecb52..81273f16 100644
--- a/pkg/config/builder.go
+++ b/pkg/config/builder.go
@@ -26,9 +26,25 @@ type Builder interface {
documentFirstPageForegroundBuilder
documentFirstPageFinalForegroundBuilder
metadataBuilder
+ documentCatalogBuilder
Build() *entity.Config
}
+type documentCatalogBuilder interface {
+ WithAcroForm(form *entity.AcroForm) Builder
+ WithAnnotations(annotations ...entity.PageAnnotation) Builder
+ WithPageGeometries(geometries ...entity.PageGeometry) Builder
+ WithPdfA(config entity.PdfAConfig) Builder
+ WithTaggedPDF(enabled bool) Builder
+ WithLanguage(language string) Builder
+ WithViewerPreferences(prefs entity.ViewerPreferences) Builder
+ WithPageLabels(labels ...entity.PageLabelRange) Builder
+ WithFileAttachments(attachments ...entity.FileAttachment) Builder
+ WithNamedDestinations(destinations ...entity.NamedDestination) Builder
+ WithFileID(id []byte) Builder
+ WithDeterministic(deterministic bool) Builder
+}
+
type dimensionsBuilder interface {
WithPageSize(size pagesize.Type) Builder
WithDimensions(width float64, height float64) Builder
@@ -159,6 +175,18 @@ type CfgBuilder struct {
watermark *props.Watermark
generationMode consts.GenerationMode
htmlLimits entity.HTMLLimits
+ acroForm *entity.AcroForm
+ annotations []entity.PageAnnotation
+ pageGeometries []entity.PageGeometry
+ pdfA *entity.PdfAConfig
+ taggedPDF bool
+ language string
+ viewerPreferences *entity.ViewerPreferences
+ pageLabels []entity.PageLabelRange
+ attachments []entity.FileAttachment
+ namedDestinations []entity.NamedDestination
+ fileID []byte
+ deterministic bool
}
// NewBuilder is responsible to create an instance of Builder.
diff --git a/pkg/config/builder_build.go b/pkg/config/builder_build.go
index ff50abdc..3a3e6cb8 100644
--- a/pkg/config/builder_build.go
+++ b/pkg/config/builder_build.go
@@ -36,9 +36,29 @@ func (b *CfgBuilder) Build() *entity.Config {
HTMLLimits: b.htmlLimits,
OutlineFromHeadings: b.outlineFromHeadings,
Watermark: props.CloneWatermark(b.watermark),
+ AcroForm: entity.CloneAcroForm(b.acroForm),
+ Annotations: entity.CloneAnnotations(b.annotations),
+ PageGeometries: entity.ClonePageGeometries(b.pageGeometries),
+ PdfA: entity.ClonePdfAConfig(b.pdfA),
+ TaggedPDF: b.taggedPDF,
+ Language: b.language,
+ ViewerPreferences: cloneViewerPreferences(b.viewerPreferences),
+ PageLabels: append([]entity.PageLabelRange(nil), b.pageLabels...),
+ Attachments: entity.CloneAttachments(b.attachments),
+ NamedDestinations: append([]entity.NamedDestination(nil), b.namedDestinations...),
+ FileID: append([]byte(nil), b.fileID...),
+ Deterministic: b.deterministic,
}
}
+func cloneViewerPreferences(prefs *entity.ViewerPreferences) *entity.ViewerPreferences {
+ if prefs == nil {
+ return nil
+ }
+ clone := *prefs
+ return &clone
+}
+
func cloneDimensions(dimensions *entity.Dimensions) *entity.Dimensions {
if dimensions == nil {
return nil
diff --git a/pkg/config/builder_catalog.go b/pkg/config/builder_catalog.go
new file mode 100644
index 00000000..b44968cc
--- /dev/null
+++ b/pkg/config/builder_catalog.go
@@ -0,0 +1,85 @@
+package config
+
+import (
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+)
+
+// WithAcroForm attaches interactive form fields to the generated PDF. The
+// form is cloned so later mutations of the input do not leak into the config.
+func (b *CfgBuilder) WithAcroForm(form *entity.AcroForm) Builder {
+ b.acroForm = entity.CloneAcroForm(form)
+ return b
+}
+
+// WithAnnotations adds page annotations to the generated PDF.
+func (b *CfgBuilder) WithAnnotations(annotations ...entity.PageAnnotation) Builder {
+ b.annotations = entity.CloneAnnotations(annotations)
+ return b
+}
+
+// WithPageGeometries configures per-page rotation and geometry boxes.
+func (b *CfgBuilder) WithPageGeometries(geometries ...entity.PageGeometry) Builder {
+ b.pageGeometries = entity.ClonePageGeometries(geometries)
+ return b
+}
+
+// WithPdfA configures PDF/A conformance metadata and output intent.
+// Level-A conformance profiles require a tagged PDF, so they enable
+// WithTaggedPDF implicitly.
+func (b *CfgBuilder) WithPdfA(config entity.PdfAConfig) Builder {
+ b.pdfA = entity.ClonePdfAConfig(&config)
+ if config.Level == entity.PdfA1A || config.Level == entity.PdfA2A || config.Level == entity.PdfA3A {
+ b.taggedPDF = true
+ }
+ return b
+}
+
+// WithTaggedPDF enables tagged (accessible) PDF output.
+func (b *CfgBuilder) WithTaggedPDF(enabled bool) Builder {
+ b.taggedPDF = enabled
+ return b
+}
+
+// WithLanguage sets the document language written to the catalog /Lang entry.
+func (b *CfgBuilder) WithLanguage(language string) Builder {
+ b.language = language
+ return b
+}
+
+// WithViewerPreferences configures catalog viewer hints.
+func (b *CfgBuilder) WithViewerPreferences(prefs entity.ViewerPreferences) Builder {
+ b.viewerPreferences = &prefs
+ return b
+}
+
+// WithPageLabels defines the labels viewers show instead of physical page
+// numbers.
+func (b *CfgBuilder) WithPageLabels(labels ...entity.PageLabelRange) Builder {
+ b.pageLabels = append([]entity.PageLabelRange(nil), labels...)
+ return b
+}
+
+// WithFileAttachments embeds files in the generated PDF.
+func (b *CfgBuilder) WithFileAttachments(attachments ...entity.FileAttachment) Builder {
+ b.attachments = entity.CloneAttachments(attachments)
+ return b
+}
+
+// WithNamedDestinations defines named destinations in the generated PDF.
+func (b *CfgBuilder) WithNamedDestinations(destinations ...entity.NamedDestination) Builder {
+ b.namedDestinations = append([]entity.NamedDestination(nil), destinations...)
+ return b
+}
+
+// WithFileID sets an explicit trailer /ID for the generated PDF.
+func (b *CfgBuilder) WithFileID(id []byte) Builder {
+ b.fileID = append([]byte(nil), id...)
+ return b
+}
+
+// WithDeterministic makes repeated builds of the same document byte-identical
+// (fixed implicit dates, sorted resources, content-derived trailer /ID).
+func (b *CfgBuilder) WithDeterministic(deterministic bool) Builder {
+ b.deterministic = deterministic
+ return b
+}
diff --git a/pkg/config/normalize.go b/pkg/config/normalize.go
index b09097dd..aaa58649 100644
--- a/pkg/config/normalize.go
+++ b/pkg/config/normalize.go
@@ -37,6 +37,18 @@ func NormalizeConfig(cfg *entity.Config) *entity.Config {
HTMLLimits: cfg.HTMLLimits,
OutlineFromHeadings: cfg.OutlineFromHeadings,
Watermark: props.CloneWatermark(cfg.Watermark),
+ AcroForm: entity.CloneAcroForm(cfg.AcroForm),
+ Annotations: entity.CloneAnnotations(cfg.Annotations),
+ PageGeometries: entity.ClonePageGeometries(cfg.PageGeometries),
+ PdfA: entity.ClonePdfAConfig(cfg.PdfA),
+ TaggedPDF: cfg.TaggedPDF,
+ Language: cfg.Language,
+ ViewerPreferences: cloneViewerPreferences(cfg.ViewerPreferences),
+ PageLabels: append([]entity.PageLabelRange(nil), cfg.PageLabels...),
+ Attachments: entity.CloneAttachments(cfg.Attachments),
+ NamedDestinations: append([]entity.NamedDestination(nil), cfg.NamedDestinations...),
+ FileID: append([]byte(nil), cfg.FileID...),
+ Deterministic: cfg.Deterministic,
}
if normalized.ProviderType == "" {
diff --git a/pkg/config/pdfa_test.go b/pkg/config/pdfa_test.go
new file mode 100644
index 00000000..bb71c313
--- /dev/null
+++ b/pkg/config/pdfa_test.go
@@ -0,0 +1,85 @@
+package config_test
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/config"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+)
+
+func TestBuilder_WithPdfAClonesInput(t *testing.T) {
+ t.Parallel()
+
+ pdfA := entity.PdfAConfig{
+ Level: entity.PdfA3B,
+ ICCProfile: []byte("profile"),
+ OutputCondition: "Custom",
+ XMPSchemas: []entity.XMPSchema{{
+ Schema: "Factur-X Schema",
+ NamespaceURI: "urn:factur-x",
+ Prefix: "fx",
+ Properties: []entity.XMPSchemaProperty{{
+ Name: "DocumentFileName",
+ ValueType: "Text",
+ Category: "external",
+ Description: "Invoice XML filename",
+ }},
+ }},
+ XMPProperties: []entity.XMPPropertyBlock{{
+ Namespace: "urn:factur-x",
+ Prefix: "fx",
+ Properties: []entity.XMPProperty{{
+ Name: "DocumentFileName",
+ Value: "factur-x.xml",
+ }},
+ }},
+ }
+ cfg := config.NewBuilder().WithPdfA(pdfA).Build()
+ pdfA.ICCProfile[0] = 'X'
+ pdfA.XMPSchemas[0].Properties[0].Name = "Mutated"
+ pdfA.XMPProperties[0].Properties[0].Value = "mutated.xml"
+
+ require.NotNil(t, cfg.PdfA)
+ assert.Equal(t, entity.PdfA3B, cfg.PdfA.Level)
+ assert.Equal(t, []byte("profile"), cfg.PdfA.ICCProfile)
+ assert.Equal(t, "Custom", cfg.PdfA.OutputCondition)
+ assert.Equal(t, "DocumentFileName", cfg.PdfA.XMPSchemas[0].Properties[0].Name)
+ assert.Equal(t, "factur-x.xml", cfg.PdfA.XMPProperties[0].Properties[0].Value)
+ assert.Equal(t, entity.PdfA3B, cfg.ToMap()["config_pdfa_level"])
+}
+
+func TestBuilder_WithPdfALevelAEnablesTaggedPDF(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().WithPdfA(entity.PdfAConfig{Level: entity.PdfA2A}).Build()
+
+ assert.True(t, cfg.TaggedPDF)
+}
+
+func TestNormalizeConfig_ClonesPdfA(t *testing.T) {
+ t.Parallel()
+
+ input := &entity.Config{PdfA: &entity.PdfAConfig{
+ ICCProfile: []byte("profile"),
+ XMPSchemas: []entity.XMPSchema{{
+ Schema: "Schema",
+ Properties: []entity.XMPSchemaProperty{{
+ Name: "Original",
+ }},
+ }},
+ XMPProperties: []entity.XMPPropertyBlock{{
+ Properties: []entity.XMPProperty{{Value: "original"}},
+ }},
+ }}
+ normalized := config.NormalizeConfig(input)
+ input.PdfA.ICCProfile[0] = 'X'
+ input.PdfA.XMPSchemas[0].Properties[0].Name = "Mutated"
+ input.PdfA.XMPProperties[0].Properties[0].Value = "mutated"
+
+ require.NotNil(t, normalized.PdfA)
+ assert.Equal(t, []byte("profile"), normalized.PdfA.ICCProfile)
+ assert.Equal(t, "Original", normalized.PdfA.XMPSchemas[0].Properties[0].Name)
+ assert.Equal(t, "original", normalized.PdfA.XMPProperties[0].Properties[0].Value)
+}
diff --git a/pkg/config/tagged_pdf_test.go b/pkg/config/tagged_pdf_test.go
new file mode 100644
index 00000000..a06bf210
--- /dev/null
+++ b/pkg/config/tagged_pdf_test.go
@@ -0,0 +1,26 @@
+package config_test
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/pkg/config"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+)
+
+func TestBuilder_WithTaggedPDF(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().WithTaggedPDF(true).Build()
+
+ assert.True(t, cfg.TaggedPDF)
+ assert.True(t, cfg.ToMap()["config_tagged_pdf"].(bool))
+}
+
+func TestNormalizeConfig_PreservesTaggedPDF(t *testing.T) {
+ t.Parallel()
+
+ normalized := config.NormalizeConfig(&entity.Config{TaggedPDF: true})
+
+ assert.True(t, normalized.TaggedPDF)
+}
diff --git a/pkg/consts/extension/extension.go b/pkg/consts/extension/extension.go
index ef683887..139f4bd2 100644
--- a/pkg/consts/extension/extension.go
+++ b/pkg/consts/extension/extension.go
@@ -13,9 +13,22 @@ const (
Png Type = "png"
// Svg represents a svg extension.
Svg Type = "svg"
+ // Gif represents a gif extension.
+ Gif Type = "gif"
+ // WebP represents a webp extension.
+ WebP Type = "webp"
+ // Tif represents a tif extension.
+ Tif Type = "tif"
+ // Tiff represents a tiff extension.
+ Tiff Type = "tiff"
)
// IsValid checks if the extension is valid.
func (t Type) IsValid() bool {
- return t == Jpg || t == Jpeg || t == Png || t == Svg
+ switch t {
+ case Jpg, Jpeg, Png, Svg, Gif, WebP, Tif, Tiff:
+ return true
+ default:
+ return false
+ }
}
diff --git a/pkg/core/entity/annotation.go b/pkg/core/entity/annotation.go
new file mode 100644
index 00000000..401d9384
--- /dev/null
+++ b/pkg/core/entity/annotation.go
@@ -0,0 +1,94 @@
+package entity
+
+import "fmt"
+
+// AnnotationType identifies a PDF annotation subtype supported by Paper.
+type AnnotationType string
+
+const (
+ AnnotationLink AnnotationType = "Link"
+ AnnotationText AnnotationType = "Text"
+ AnnotationHighlight AnnotationType = "Highlight"
+ AnnotationUnderline AnnotationType = "Underline"
+ AnnotationSquiggly AnnotationType = "Squiggly"
+ AnnotationStrikeOut AnnotationType = "StrikeOut"
+)
+
+// MarkupType identifies the kind of text markup annotation.
+type MarkupType int
+
+const (
+ MarkupHighlight MarkupType = iota
+ MarkupUnderline
+ MarkupSquiggly
+ MarkupStrikeOut
+)
+
+// PageAnnotation describes an annotation emitted on a generated PDF page.
+type PageAnnotation struct {
+ PageIndex int
+ Type AnnotationType
+ Rect [4]float64
+ URI string
+ DestName string
+ DestPage *int
+ Contents string
+ Icon string
+ Open bool
+ Color *[3]float64
+ QuadPoints [][8]float64
+}
+
+func appendAnnotationsMap(annotations []PageAnnotation, m map[string]any) map[string]any {
+ if len(annotations) == 0 {
+ return m
+ }
+ m["config_annotations"] = len(annotations)
+ for i, annotation := range annotations {
+ prefix := fmt.Sprintf("config_annotation_%d", i)
+ m[prefix+"_page_index"] = annotation.PageIndex
+ if annotation.Type != "" {
+ m[prefix+"_type"] = annotation.Type
+ }
+ if annotation.URI != "" {
+ m[prefix+"_uri"] = annotation.URI
+ }
+ if annotation.DestName != "" {
+ m[prefix+"_dest_name"] = annotation.DestName
+ }
+ if annotation.DestPage != nil {
+ m[prefix+"_dest_page"] = *annotation.DestPage
+ }
+ if annotation.Contents != "" {
+ m[prefix+"_contents"] = annotation.Contents
+ }
+ if annotation.Icon != "" {
+ m[prefix+"_icon"] = annotation.Icon
+ }
+ if annotation.Open {
+ m[prefix+"_open"] = annotation.Open
+ }
+ }
+ return m
+}
+
+// CloneAnnotations returns an independent copy of annotations.
+func CloneAnnotations(annotations []PageAnnotation) []PageAnnotation {
+ if annotations == nil {
+ return nil
+ }
+ clones := make([]PageAnnotation, len(annotations))
+ for i, annotation := range annotations {
+ clones[i] = annotation
+ if annotation.Color != nil {
+ color := *annotation.Color
+ clones[i].Color = &color
+ }
+ if annotation.DestPage != nil {
+ destPage := *annotation.DestPage
+ clones[i].DestPage = &destPage
+ }
+ clones[i].QuadPoints = append([][8]float64(nil), annotation.QuadPoints...)
+ }
+ return clones
+}
diff --git a/pkg/core/entity/attachment.go b/pkg/core/entity/attachment.go
new file mode 100644
index 00000000..e1e3ca3a
--- /dev/null
+++ b/pkg/core/entity/attachment.go
@@ -0,0 +1,63 @@
+package entity
+
+import (
+ "fmt"
+ "time"
+)
+
+// FileAttachment describes a file embedded in the generated PDF.
+type FileAttachment struct {
+ // FileName is the name shown by PDF viewers and stored in the file
+ // specification dictionary.
+ FileName string
+ // MIMEType is written as the embedded file stream subtype. It defaults to
+ // application/octet-stream when empty.
+ MIMEType string
+ // Description is an optional human-readable file description.
+ Description string
+ // AFRelationship is the PDF associated-file relationship name. It defaults
+ // to Unspecified when empty.
+ AFRelationship string
+ // Data is the raw file content to embed.
+ Data []byte
+ // CreationDate is written to the embedded file parameters. If zero, the
+ // document creation date or generation time is used.
+ CreationDate time.Time
+}
+
+func appendFileAttachmentsMap(attachments []FileAttachment, m map[string]any) map[string]any {
+ for i, attachment := range attachments {
+ prefix := fmt.Sprintf("config_file_attachment_%d", i)
+ if attachment.FileName != "" {
+ m[prefix+"_filename"] = attachment.FileName
+ }
+ if attachment.MIMEType != "" {
+ m[prefix+"_mime_type"] = attachment.MIMEType
+ }
+ if attachment.Description != "" {
+ m[prefix+"_description"] = attachment.Description
+ }
+ if attachment.AFRelationship != "" {
+ m[prefix+"_af_relationship"] = attachment.AFRelationship
+ }
+ m[prefix+"_size"] = len(attachment.Data)
+ if !attachment.CreationDate.IsZero() {
+ m[prefix+"_creation_date"] = attachment.CreationDate
+ }
+ }
+ return m
+}
+
+// CloneAttachments returns an independent copy of attachments, including the
+// embedded data bytes.
+func CloneAttachments(attachments []FileAttachment) []FileAttachment {
+ if len(attachments) == 0 {
+ return nil
+ }
+ clones := make([]FileAttachment, len(attachments))
+ for i, attachment := range attachments {
+ attachment.Data = append([]byte(nil), attachment.Data...)
+ clones[i] = attachment
+ }
+ return clones
+}
diff --git a/pkg/core/entity/config.go b/pkg/core/entity/config.go
index 3b208960..2a376e18 100644
--- a/pkg/core/entity/config.go
+++ b/pkg/core/entity/config.go
@@ -29,6 +29,39 @@ type Config struct {
HTMLLimits HTMLLimits
OutlineFromHeadings bool
Watermark *props.Watermark
+ Annotations []PageAnnotation
+ PageGeometries []PageGeometry
+ AcroForm *AcroForm
+ PdfA *PdfAConfig
+ TaggedPDF bool
+ Language string
+ ViewerPreferences *ViewerPreferences
+ PageLabels []PageLabelRange
+ Attachments []FileAttachment
+ NamedDestinations []NamedDestination
+ FileID []byte
+ Deterministic bool
+}
+
+// HasDocumentCatalog reports whether any document-catalog feature is
+// configured. These features are written once per document, so generation
+// falls back to sequential mode when any of them is present.
+func (c *Config) HasDocumentCatalog() bool {
+ if c == nil {
+ return false
+ }
+ return c.AcroForm != nil ||
+ len(c.Annotations) > 0 ||
+ len(c.PageGeometries) > 0 ||
+ c.PdfA != nil ||
+ c.TaggedPDF ||
+ c.Language != "" ||
+ c.ViewerPreferences != nil ||
+ len(c.PageLabels) > 0 ||
+ len(c.Attachments) > 0 ||
+ len(c.NamedDestinations) > 0 ||
+ len(c.FileID) > 0 ||
+ c.Deterministic
}
// ToMap converts Config to a map[string]any .
@@ -127,5 +160,36 @@ func (c *Config) ToMap() map[string]any {
m["config_html_max_style_rules"] = c.HTMLLimits.MaxStyleRules
}
+ return c.appendCatalogMap(m)
+}
+
+// appendCatalogMap adds the document-catalog feature entries to a config map.
+func (c *Config) appendCatalogMap(m map[string]any) map[string]any {
+ m = appendAcroFormMap(c.AcroForm, m)
+ m = appendAnnotationsMap(c.Annotations, m)
+ m = appendPageGeometriesMap(c.PageGeometries, m)
+ if c.PdfA != nil {
+ m["config_pdfa_level"] = c.PdfA.Level
+ if c.PdfA.OutputCondition != "" {
+ m["config_pdfa_output_condition"] = c.PdfA.OutputCondition
+ }
+ }
+ if c.TaggedPDF {
+ m["config_tagged_pdf"] = c.TaggedPDF
+ }
+ if c.Language != "" {
+ m["config_language"] = c.Language
+ }
+ m = c.ViewerPreferences.AppendMap(m)
+ m = appendPageLabelsMap(c.PageLabels, m)
+ m = appendFileAttachmentsMap(c.Attachments, m)
+ m = appendNamedDestinationsMap(c.NamedDestinations, m)
+ if len(c.FileID) > 0 {
+ m["config_file_id_bytes"] = len(c.FileID)
+ }
+ if c.Deterministic {
+ m["config_deterministic"] = c.Deterministic
+ }
+
return m
}
diff --git a/pkg/core/entity/form.go b/pkg/core/entity/form.go
new file mode 100644
index 00000000..20544c8a
--- /dev/null
+++ b/pkg/core/entity/form.go
@@ -0,0 +1,305 @@
+package entity
+
+import "fmt"
+
+// FieldType identifies the kind of interactive AcroForm field.
+type FieldType int
+
+const (
+ FieldText FieldType = iota
+ FieldCheckbox
+ FieldRadio
+ FieldDropdown
+ FieldListBox
+ FieldPushButton
+ FieldSignature
+)
+
+// FieldFlags are AcroForm field flags from ISO 32000 section 12.7.3.
+type FieldFlags uint32
+
+const (
+ FlagReadOnly FieldFlags = 1 << 0
+ FlagRequired FieldFlags = 1 << 1
+ FlagNoExport FieldFlags = 1 << 2
+
+ FlagMultiline FieldFlags = 1 << 12
+ FlagPassword FieldFlags = 1 << 13
+ FlagFileSelect FieldFlags = 1 << 20
+ FlagDoNotSpellCheck FieldFlags = 1 << 22
+ FlagDoNotScroll FieldFlags = 1 << 23
+ FlagComb FieldFlags = 1 << 24
+ FlagRichText FieldFlags = 1 << 25
+
+ FlagCombo FieldFlags = 1 << 17
+ FlagEdit FieldFlags = 1 << 18
+ FlagSort FieldFlags = 1 << 19
+ FlagMultiSelect FieldFlags = 1 << 21
+
+ FlagNoToggleToOff FieldFlags = 1 << 14
+ FlagRadioFlag FieldFlags = 1 << 15
+ FlagPushButtonFlag FieldFlags = 1 << 16
+)
+
+// RadioOption defines one button in a radio group.
+type RadioOption struct {
+ Value string
+ Rect [4]float64
+ PageIndex int
+}
+
+// Field represents a generated PDF AcroForm field.
+type Field struct {
+ Name string
+ Type FieldType
+ Value string
+ Default string
+ Flags FieldFlags
+ Rect [4]float64
+ PageIndex int
+
+ FontSize float64
+ FontName string
+ TextColor [3]float64
+ BGColor *[3]float64
+ BorderColor *[3]float64
+ BorderWidth float64
+
+ Options []string
+ ExportValue string
+
+ children []*Field
+}
+
+// AcroForm manages generated PDF interactive form fields.
+type AcroForm struct {
+ fields []*Field
+}
+
+// NewAcroForm creates an empty AcroForm.
+func NewAcroForm() *AcroForm {
+ return &AcroForm{}
+}
+
+// Add appends a field and returns the form for chaining.
+func (af *AcroForm) Add(f *Field) *AcroForm {
+ if af == nil {
+ return af
+ }
+ if f != nil {
+ af.fields = append(af.fields, f)
+ }
+ return af
+}
+
+// Fields returns the top-level fields in insertion order.
+func (af *AcroForm) Fields() []*Field {
+ if af == nil {
+ return nil
+ }
+ return af.fields
+}
+
+// NewTextField creates a single-line text input field.
+func NewTextField(name string, rect [4]float64, pageIndex int) *Field {
+ return &Field{
+ Name: name,
+ Type: FieldText,
+ Rect: rect,
+ PageIndex: pageIndex,
+ FontSize: 12,
+ FontName: "Helv",
+ BorderWidth: 1,
+ }
+}
+
+// NewMultilineTextField creates a multi-line text area field.
+func NewMultilineTextField(name string, rect [4]float64, pageIndex int) *Field {
+ f := NewTextField(name, rect, pageIndex)
+ f.Flags |= FlagMultiline
+ return f
+}
+
+// NewPasswordField creates a password text field.
+func NewPasswordField(name string, rect [4]float64, pageIndex int) *Field {
+ f := NewTextField(name, rect, pageIndex)
+ f.Flags |= FlagPassword
+ return f
+}
+
+// NewCheckbox creates a checkbox field.
+func NewCheckbox(name string, rect [4]float64, pageIndex int, checked bool) *Field {
+ f := &Field{
+ Name: name,
+ Type: FieldCheckbox,
+ Rect: rect,
+ PageIndex: pageIndex,
+ ExportValue: "Yes",
+ BorderWidth: 1,
+ }
+ if checked {
+ f.Value = "Yes"
+ } else {
+ f.Value = "Off"
+ }
+ return f
+}
+
+// NewRadioGroup creates a radio button group.
+func NewRadioGroup(name string, options []RadioOption) *Field {
+ parent := &Field{
+ Name: name,
+ Type: FieldRadio,
+ Flags: FlagRadioFlag | FlagNoToggleToOff,
+ }
+ for _, opt := range options {
+ child := &Field{
+ Name: opt.Value,
+ Type: FieldRadio,
+ Rect: opt.Rect,
+ PageIndex: opt.PageIndex,
+ ExportValue: opt.Value,
+ BorderWidth: 1,
+ }
+ parent.children = append(parent.children, child)
+ }
+ return parent
+}
+
+// NewDropdown creates a combo-box choice field.
+func NewDropdown(name string, rect [4]float64, pageIndex int, options []string) *Field {
+ return &Field{
+ Name: name,
+ Type: FieldDropdown,
+ Rect: rect,
+ PageIndex: pageIndex,
+ Options: append([]string(nil), options...),
+ Flags: FlagCombo,
+ FontSize: 12,
+ FontName: "Helv",
+ BorderWidth: 1,
+ }
+}
+
+// NewListBox creates a list-box choice field.
+func NewListBox(name string, rect [4]float64, pageIndex int, options []string) *Field {
+ return &Field{
+ Name: name,
+ Type: FieldListBox,
+ Rect: rect,
+ PageIndex: pageIndex,
+ Options: append([]string(nil), options...),
+ FontSize: 12,
+ FontName: "Helv",
+ BorderWidth: 1,
+ }
+}
+
+// NewSignatureField creates an unsigned signature field.
+func NewSignatureField(name string, rect [4]float64, pageIndex int) *Field {
+ return &Field{
+ Name: name,
+ Type: FieldSignature,
+ Rect: rect,
+ PageIndex: pageIndex,
+ BorderWidth: 1,
+ }
+}
+
+// Children returns radio widget children for a radio group field.
+func (f *Field) Children() []*Field {
+ if f == nil {
+ return nil
+ }
+ return f.children
+}
+
+// SetValue sets the field's current value.
+func (f *Field) SetValue(v string) *Field {
+ if f != nil {
+ f.Value = v
+ }
+ return f
+}
+
+// SetReadOnly marks the field read-only.
+func (f *Field) SetReadOnly() *Field {
+ if f != nil {
+ f.Flags |= FlagReadOnly
+ }
+ return f
+}
+
+// SetRequired marks the field required.
+func (f *Field) SetRequired() *Field {
+ if f != nil {
+ f.Flags |= FlagRequired
+ }
+ return f
+}
+
+// SetBackgroundColor sets the field widget background color as RGB values in [0, 1].
+func (f *Field) SetBackgroundColor(r, g, b float64) *Field {
+ if f != nil {
+ f.BGColor = &[3]float64{r, g, b}
+ }
+ return f
+}
+
+// SetBorderColor sets the field widget border color as RGB values in [0, 1].
+func (f *Field) SetBorderColor(r, g, b float64) *Field {
+ if f != nil {
+ f.BorderColor = &[3]float64{r, g, b}
+ }
+ return f
+}
+
+// CloneAcroForm returns a deep copy of form.
+func CloneAcroForm(form *AcroForm) *AcroForm {
+ if form == nil {
+ return nil
+ }
+ clone := &AcroForm{fields: make([]*Field, 0, len(form.fields))}
+ for _, field := range form.fields {
+ clone.fields = append(clone.fields, CloneField(field))
+ }
+ return clone
+}
+
+// CloneField returns a deep copy of field.
+func CloneField(field *Field) *Field {
+ if field == nil {
+ return nil
+ }
+ clone := *field
+ clone.BGColor = cloneColorTriple(field.BGColor)
+ clone.BorderColor = cloneColorTriple(field.BorderColor)
+ clone.Options = append([]string(nil), field.Options...)
+ clone.children = make([]*Field, 0, len(field.children))
+ for _, child := range field.children {
+ clone.children = append(clone.children, CloneField(child))
+ }
+ return &clone
+}
+
+func cloneColorTriple(color *[3]float64) *[3]float64 {
+ if color == nil {
+ return nil
+ }
+ clone := *color
+ return &clone
+}
+
+func appendAcroFormMap(form *AcroForm, m map[string]any) map[string]any {
+ if form == nil || len(form.fields) == 0 {
+ return m
+ }
+ m["config_acroform_fields"] = len(form.fields)
+ for i, field := range form.fields {
+ prefix := fmt.Sprintf("config_acroform_field_%d", i)
+ m[prefix+"_name"] = field.Name
+ m[prefix+"_type"] = field.Type
+ m[prefix+"_page_index"] = field.PageIndex
+ }
+ return m
+}
diff --git a/pkg/core/entity/form_extra_test.go b/pkg/core/entity/form_extra_test.go
new file mode 100644
index 00000000..90127c55
--- /dev/null
+++ b/pkg/core/entity/form_extra_test.go
@@ -0,0 +1,37 @@
+package entity_test
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+)
+
+func TestEntityNewMultilineTextField(t *testing.T) {
+ t.Parallel()
+
+ field := entity.NewMultilineTextField("notes", [4]float64{1, 2, 3, 4}, 0)
+
+ assert.Equal(t, entity.FieldText, field.Type)
+ assert.True(t, field.Flags&entity.FlagMultiline != 0)
+}
+
+func TestEntityNewPasswordField(t *testing.T) {
+ t.Parallel()
+
+ field := entity.NewPasswordField("secret", [4]float64{1, 2, 3, 4}, 0)
+
+ assert.Equal(t, entity.FieldText, field.Type)
+ assert.True(t, field.Flags&entity.FlagPassword != 0)
+}
+
+func TestConfigToMapIncludesAcroFormDetails(t *testing.T) {
+ t.Parallel()
+
+ form := entity.NewAcroForm().Add(entity.NewTextField("name", [4]float64{1, 2, 3, 4}, 0))
+ cfg := &entity.Config{AcroForm: form}
+
+ m := cfg.ToMap()
+ assert.Equal(t, 1, m["config_acroform_fields"])
+ assert.Equal(t, "name", m["config_acroform_field_0_name"])
+}
diff --git a/pkg/core/entity/named_destination.go b/pkg/core/entity/named_destination.go
new file mode 100644
index 00000000..573b6316
--- /dev/null
+++ b/pkg/core/entity/named_destination.go
@@ -0,0 +1,51 @@
+package entity
+
+import "fmt"
+
+// DestinationFitType is the PDF destination fit operator for a named destination.
+type DestinationFitType string
+
+const (
+ // DestinationFit fits the full page in the viewer.
+ DestinationFit DestinationFitType = "Fit"
+ // DestinationFitH fits the page width at the provided top coordinate.
+ DestinationFitH DestinationFitType = "FitH"
+ // DestinationXYZ opens the page at the provided left/top/zoom values.
+ DestinationXYZ DestinationFitType = "XYZ"
+)
+
+// NamedDestination defines a named location in the generated PDF.
+type NamedDestination struct {
+ Name string
+ PageIndex int
+ FitType DestinationFitType
+ Top float64
+ Left float64
+ Zoom float64
+}
+
+// NamedDest is a compatibility alias for Folio-style naming.
+type NamedDest = NamedDestination
+
+func appendNamedDestinationsMap(destinations []NamedDestination, m map[string]any) map[string]any {
+ for i, destination := range destinations {
+ prefix := fmt.Sprintf("config_named_destination_%d", i)
+ if destination.Name != "" {
+ m[prefix+"_name"] = destination.Name
+ }
+ m[prefix+"_page_index"] = destination.PageIndex
+ if destination.FitType != "" {
+ m[prefix+"_fit_type"] = destination.FitType
+ }
+ if destination.Top != 0 {
+ m[prefix+"_top"] = destination.Top
+ }
+ if destination.Left != 0 {
+ m[prefix+"_left"] = destination.Left
+ }
+ if destination.Zoom != 0 {
+ m[prefix+"_zoom"] = destination.Zoom
+ }
+ }
+ return m
+}
diff --git a/pkg/core/entity/page_geometry.go b/pkg/core/entity/page_geometry.go
new file mode 100644
index 00000000..77ec0b4e
--- /dev/null
+++ b/pkg/core/entity/page_geometry.go
@@ -0,0 +1,64 @@
+package entity
+
+import "fmt"
+
+// PageGeometry configures generated PDF page dictionary geometry entries.
+type PageGeometry struct {
+ PageIndex int
+ Rotate int
+ CropBox *[4]float64
+ BleedBox *[4]float64
+ TrimBox *[4]float64
+ ArtBox *[4]float64
+}
+
+func appendPageGeometriesMap(geometries []PageGeometry, m map[string]any) map[string]any {
+ if len(geometries) == 0 {
+ return m
+ }
+ m["config_page_geometries"] = len(geometries)
+ for i, geometry := range geometries {
+ prefix := fmt.Sprintf("config_page_geometry_%d", i)
+ m[prefix+"_page_index"] = geometry.PageIndex
+ if geometry.Rotate != 0 {
+ m[prefix+"_rotate"] = geometry.Rotate
+ }
+ if geometry.CropBox != nil {
+ m[prefix+"_crop_box"] = *geometry.CropBox
+ }
+ if geometry.BleedBox != nil {
+ m[prefix+"_bleed_box"] = *geometry.BleedBox
+ }
+ if geometry.TrimBox != nil {
+ m[prefix+"_trim_box"] = *geometry.TrimBox
+ }
+ if geometry.ArtBox != nil {
+ m[prefix+"_art_box"] = *geometry.ArtBox
+ }
+ }
+ return m
+}
+
+// ClonePageGeometries returns an independent copy of page geometries.
+func ClonePageGeometries(geometries []PageGeometry) []PageGeometry {
+ if geometries == nil {
+ return nil
+ }
+ clones := make([]PageGeometry, len(geometries))
+ for i, geometry := range geometries {
+ clones[i] = geometry
+ clones[i].CropBox = cloneBox(geometry.CropBox)
+ clones[i].BleedBox = cloneBox(geometry.BleedBox)
+ clones[i].TrimBox = cloneBox(geometry.TrimBox)
+ clones[i].ArtBox = cloneBox(geometry.ArtBox)
+ }
+ return clones
+}
+
+func cloneBox(box *[4]float64) *[4]float64 {
+ if box == nil {
+ return nil
+ }
+ clone := *box
+ return &clone
+}
diff --git a/pkg/core/entity/page_label.go b/pkg/core/entity/page_label.go
new file mode 100644
index 00000000..1286c15a
--- /dev/null
+++ b/pkg/core/entity/page_label.go
@@ -0,0 +1,43 @@
+package entity
+
+import "fmt"
+
+type LabelStyle string
+
+const (
+ LabelDecimal LabelStyle = "D"
+ LabelRomanUpper LabelStyle = "R"
+ LabelRomanLower LabelStyle = "r"
+ LabelAlphaUpper LabelStyle = "A"
+ LabelAlphaLower LabelStyle = "a"
+ LabelNone LabelStyle = ""
+)
+
+type PageLabelRange struct {
+ PageIndex int
+ Style LabelStyle
+ Prefix string
+ Start int
+}
+
+func appendPageLabelsMap(labels []PageLabelRange, m map[string]any) map[string]any {
+ for i, label := range labels {
+ prefix := fmt.Sprintf("config_page_label_%d_", i)
+ if label.PageIndex != 0 {
+ m[prefix+"page_index"] = label.PageIndex
+ }
+ if label.Style != "" {
+ m[prefix+"style"] = label.Style
+ }
+ if label.Prefix != "" {
+ m[prefix+"prefix"] = label.Prefix
+ }
+ if label.Start != 0 {
+ m[prefix+"start"] = label.Start
+ }
+ }
+ if len(labels) > 0 {
+ m["config_page_label_count"] = len(labels)
+ }
+ return m
+}
diff --git a/pkg/core/entity/pdfa.go b/pkg/core/entity/pdfa.go
new file mode 100644
index 00000000..31ecd597
--- /dev/null
+++ b/pkg/core/entity/pdfa.go
@@ -0,0 +1,91 @@
+package entity
+
+// PdfALevel identifies the requested PDF/A part and conformance profile.
+type PdfALevel int
+
+const (
+ PdfA2B PdfALevel = iota
+ PdfA2U
+ PdfA2A
+ PdfA3B
+ PdfA1B
+ PdfA1A
+ PdfA3A
+ PdfA4
+ PdfA4F
+ PdfA4E
+)
+
+// PdfAConfig configures generated PDF/A identification metadata.
+type PdfAConfig struct {
+ Level PdfALevel
+ ICCProfile []byte
+ OutputCondition string
+ XMPSchemas []XMPSchema
+ XMPProperties []XMPPropertyBlock
+}
+
+// XMPSchema describes one PDF/A extension schema declaration.
+type XMPSchema struct {
+ Schema string
+ NamespaceURI string
+ Prefix string
+ Properties []XMPSchemaProperty
+}
+
+// XMPSchemaProperty declares one property within an XMP extension schema.
+type XMPSchemaProperty struct {
+ Name string
+ ValueType string
+ Category string
+ Description string
+}
+
+// XMPPropertyBlock carries actual XMP property values for a namespace.
+type XMPPropertyBlock struct {
+ Namespace string
+ Prefix string
+ Properties []XMPProperty
+}
+
+// XMPProperty is a single custom XMP property value.
+type XMPProperty struct {
+ Name string
+ Value string
+}
+
+// ClonePdfAConfig returns a deep copy of config.
+func ClonePdfAConfig(config *PdfAConfig) *PdfAConfig {
+ if config == nil {
+ return nil
+ }
+ clone := *config
+ clone.ICCProfile = append([]byte(nil), config.ICCProfile...)
+ clone.XMPSchemas = cloneXMPSchemas(config.XMPSchemas)
+ clone.XMPProperties = cloneXMPPropertyBlocks(config.XMPProperties)
+ return &clone
+}
+
+func cloneXMPSchemas(schemas []XMPSchema) []XMPSchema {
+ if len(schemas) == 0 {
+ return nil
+ }
+ clones := make([]XMPSchema, len(schemas))
+ for i, schema := range schemas {
+ clones[i] = schema
+ clones[i].Properties = append([]XMPSchemaProperty(nil), schema.Properties...)
+ }
+ return clones
+}
+
+func cloneXMPPropertyBlocks(blocks []XMPPropertyBlock) []XMPPropertyBlock {
+ if len(blocks) == 0 {
+ return nil
+ }
+ clones := make([]XMPPropertyBlock, len(blocks))
+ for i, block := range blocks {
+ clones[i] = block
+ clones[i].Properties = append([]XMPProperty(nil), block.Properties...)
+ }
+ return clones
+}
diff --git a/pkg/core/entity/viewer.go b/pkg/core/entity/viewer.go
new file mode 100644
index 00000000..903a428a
--- /dev/null
+++ b/pkg/core/entity/viewer.go
@@ -0,0 +1,73 @@
+package entity
+
+type PageLayout string
+
+const (
+ LayoutSinglePage PageLayout = "SinglePage"
+ LayoutOneColumn PageLayout = "OneColumn"
+ LayoutTwoColumnLeft PageLayout = "TwoColumnLeft"
+ LayoutTwoColumnRight PageLayout = "TwoColumnRight"
+ LayoutTwoPageLeft PageLayout = "TwoPageLeft"
+ LayoutTwoPageRight PageLayout = "TwoPageRight"
+)
+
+type PageMode string
+
+const (
+ ModeUseNone PageMode = "UseNone"
+ ModeUseOutlines PageMode = "UseOutlines"
+ ModeUseThumbs PageMode = "UseThumbs"
+ ModeFullScreen PageMode = "FullScreen"
+ ModeUseOC PageMode = "UseOC"
+ ModeUseAttachments PageMode = "UseAttachments"
+)
+
+type ViewerPreferences struct {
+ PageLayout PageLayout
+ PageMode PageMode
+ HideToolbar bool
+ HideMenubar bool
+ HideWindowUI bool
+ FitWindow bool
+ CenterWindow bool
+ DisplayDocTitle bool
+ OpenPage int
+ OpenZoom string
+}
+
+func (v *ViewerPreferences) AppendMap(m map[string]any) map[string]any {
+ if v == nil {
+ return m
+ }
+ if v.PageLayout != "" {
+ m["config_viewer_page_layout"] = v.PageLayout
+ }
+ if v.PageMode != "" {
+ m["config_viewer_page_mode"] = v.PageMode
+ }
+ if v.HideToolbar {
+ m["config_viewer_hide_toolbar"] = v.HideToolbar
+ }
+ if v.HideMenubar {
+ m["config_viewer_hide_menubar"] = v.HideMenubar
+ }
+ if v.HideWindowUI {
+ m["config_viewer_hide_window_ui"] = v.HideWindowUI
+ }
+ if v.FitWindow {
+ m["config_viewer_fit_window"] = v.FitWindow
+ }
+ if v.CenterWindow {
+ m["config_viewer_center_window"] = v.CenterWindow
+ }
+ if v.DisplayDocTitle {
+ m["config_viewer_display_doc_title"] = v.DisplayDocTitle
+ }
+ if v.OpenPage > 0 || v.OpenZoom != "" {
+ m["config_viewer_open_page"] = v.OpenPage
+ }
+ if v.OpenZoom != "" {
+ m["config_viewer_open_zoom"] = v.OpenZoom
+ }
+ return m
+}
diff --git a/pkg/core/entity/viewer_test.go b/pkg/core/entity/viewer_test.go
new file mode 100644
index 00000000..2b74a113
--- /dev/null
+++ b/pkg/core/entity/viewer_test.go
@@ -0,0 +1,24 @@
+package entity_test
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+)
+
+func TestViewerPreferences_AppendMap(t *testing.T) {
+ t.Parallel()
+
+ m := (&entity.ViewerPreferences{
+ PageLayout: entity.LayoutTwoPageRight,
+ PageMode: entity.ModeUseThumbs,
+ OpenPage: 2,
+ OpenZoom: "FitH",
+ }).AppendMap(map[string]any{})
+
+ assert.Equal(t, entity.LayoutTwoPageRight, m["config_viewer_page_layout"])
+ assert.Equal(t, entity.ModeUseThumbs, m["config_viewer_page_mode"])
+ assert.Equal(t, 2, m["config_viewer_open_page"])
+ assert.Equal(t, "FitH", m["config_viewer_open_zoom"])
+}
diff --git a/pkg/core/layered_row.go b/pkg/core/layered_row.go
new file mode 100644
index 00000000..80f567d5
--- /dev/null
+++ b/pkg/core/layered_row.go
@@ -0,0 +1,10 @@
+package core
+
+// LayeredRow is an optional capability for rows that render out of the normal
+// flow paint order. Negative layers paint behind the page's flow rows,
+// positive layers in front; 0 (or not implementing the interface) is normal
+// flow order. Used by CSS positioned rows carrying a z-index.
+type LayeredRow interface {
+ // RenderLayer returns the paint layer for the row.
+ RenderLayer() int
+}
diff --git a/pkg/core/page_context_provider.go b/pkg/core/page_context_provider.go
new file mode 100644
index 00000000..31353dc0
--- /dev/null
+++ b/pkg/core/page_context_provider.go
@@ -0,0 +1,19 @@
+package core
+
+// PageContextProvider is an optional capability for providers that expose the
+// physical page context (current page index, total pages, and running strings
+// such as CSS running headers) to components while they render.
+//
+// Consumers detect support via the safe type-assertion idiom:
+//
+// if pcp, ok := provider.(core.PageContextProvider); ok {
+// pcp.WithPageContextStrings(current, total, runningStrings, fn)
+// }
+type PageContextProvider interface {
+ // WithPageContext runs fn with the current/total page context installed,
+ // restoring the previous context afterwards.
+ WithPageContext(current, total int, fn func())
+ // WithPageContextStrings is WithPageContext with named running strings
+ // (e.g. the active chapter title) additionally in scope.
+ WithPageContextStrings(current, total int, runningStrings map[string]string, fn func())
+}
diff --git a/pkg/core/transform_provider.go b/pkg/core/transform_provider.go
new file mode 100644
index 00000000..587ff2c5
--- /dev/null
+++ b/pkg/core/transform_provider.go
@@ -0,0 +1,15 @@
+package core
+
+import (
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+// TransformProvider is an optional capability for providers that can wrap
+// rendering in a temporary 2D transform context. Coordinates and translate
+// distances are margin-relative millimetres, matching entity.Cell.
+type TransformProvider interface {
+ // WithTransform applies ops around the origin point relative to cell's
+ // top-left corner, renders fn, then restores the provider transform state.
+ WithTransform(cell *entity.Cell, originX, originY float64, ops []props.TransformOp, fn func())
+}
diff --git a/pkg/forms/fill.go b/pkg/forms/fill.go
new file mode 100644
index 00000000..a3e28cfd
--- /dev/null
+++ b/pkg/forms/fill.go
@@ -0,0 +1,1163 @@
+package forms
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/avdoseferovic/paper/pkg/reader"
+)
+
+// FormFiller reads AcroForm fields from an existing PDF and writes filled or
+// flattened PDF bytes.
+type FormFiller struct {
+ data []byte
+}
+
+type formFieldObject struct {
+ objNum int
+ content []byte
+ name string
+ fieldType string
+ value string
+ exportName string
+}
+
+type formObject struct {
+ content []byte
+}
+
+type formPageObject struct {
+ content []byte
+ resourcesObjNum int
+}
+
+type formPDFInfo struct {
+ data []byte
+ objects map[int]formObject
+ rootObjNum int
+ prevXref int64
+ size int
+ maxObjectNumber int
+ infoRef string
+ idValue string
+}
+
+var (
+ errNilFormReader = errors.New("forms: reader is nil")
+ errFormFieldNotFound = errors.New("forms: field not found")
+ errFormNoObjects = errors.New("forms: no indirect objects found")
+ errFormNoStartXref = errors.New("forms: startxref not found")
+ errFormXrefOutOfBounds = errors.New("forms: xref offset out of bounds")
+ errFormNoTrailer = errors.New("forms: trailer not found")
+ errFormNoRoot = errors.New("forms: trailer has no /Root")
+ errFormBadDictionary = errors.New("forms: field dictionary is malformed")
+
+ formObjectRe = regexp.MustCompile(`(?s)(\d+)\s+\d+\s+obj\s*(.*?)\s*endobj`)
+ formRootRe = regexp.MustCompile(`/Root\s+(\d+)\s+\d+\s+R`)
+ formSizeRe = regexp.MustCompile(`/Size\s+(\d+)`)
+ formInfoRe = regexp.MustCompile(`/Info\s+(\d+\s+\d+\s+R)`)
+ formIDRe = regexp.MustCompile(`(?s)/ID\s*(\[[^\]]+\])`)
+ formAPNRe = regexp.MustCompile(`(?s)/AP\s*<<\s*/N\s*<<(.*?)>>`)
+ formNameRe = regexp.MustCompile(`/([A-Za-z0-9_.#-]+)`)
+)
+
+const (
+ defaultCheckboxExportName = "Yes"
+ formOffState = "Off"
+)
+
+// NewFormFiller creates a filler from a parsed PDF.
+func NewFormFiller(r *reader.PdfReader) *FormFiller {
+ if r == nil {
+ return &FormFiller{}
+ }
+ return &FormFiller{data: r.RawBytes()}
+}
+
+// Bytes returns the current PDF bytes after any appended form updates.
+func (ff *FormFiller) Bytes() []byte {
+ if ff == nil {
+ return nil
+ }
+ return append([]byte(nil), ff.data...)
+}
+
+// SaveTo writes the current PDF bytes to path.
+func (ff *FormFiller) SaveTo(path string) error {
+ if ff == nil || ff.data == nil {
+ return errNilFormReader
+ }
+ err := os.WriteFile(path, ff.data, 0o600)
+ if err != nil {
+ return fmt.Errorf("forms: save: %w", err)
+ }
+ return nil
+}
+
+// FieldNames returns form field names in object order.
+func (ff *FormFiller) FieldNames() ([]string, error) {
+ fields, err := ff.fields()
+ if err != nil {
+ return nil, err
+ }
+ names := make([]string, 0, len(fields))
+ for _, field := range fields {
+ names = append(names, field.name)
+ }
+ return names, nil
+}
+
+// GetValue returns the current value of fieldName.
+func (ff *FormFiller) GetValue(fieldName string) (string, error) {
+ field, err := ff.field(fieldName)
+ if err != nil {
+ return "", err
+ }
+ return field.value, nil
+}
+
+// SetValue sets a text or choice field value by appending a replacement field object.
+func (ff *FormFiller) SetValue(fieldName, value string) error {
+ field, err := ff.field(fieldName)
+ if err != nil {
+ return err
+ }
+ updated, err := setFormDictionaryEntry(field.content, "V", pdfLiteralString(value))
+ if err != nil {
+ return err
+ }
+ return ff.appendFieldUpdate(field.objNum, updated)
+}
+
+// SetCheckbox sets a checkbox field's /V and /AS values.
+func (ff *FormFiller) SetCheckbox(fieldName string, checked bool) error {
+ field, err := ff.field(fieldName)
+ if err != nil {
+ return err
+ }
+ state := formOffState
+ if checked {
+ state = field.exportName
+ if state == "" {
+ state = defaultCheckboxExportName
+ }
+ }
+ updated, err := setFormDictionaryEntry(field.content, "V", "/"+state)
+ if err != nil {
+ return err
+ }
+ updated, err = setFormDictionaryEntry(updated, "AS", "/"+state)
+ if err != nil {
+ return err
+ }
+ return ff.appendFieldUpdate(field.objNum, updated)
+}
+
+// Flatten renders supported AcroForm field values into page content and
+// rewrites the PDF without active AcroForm/widget objects.
+func (ff *FormFiller) Flatten() error {
+ if ff == nil || len(ff.data) == 0 {
+ return errNilFormReader
+ }
+ info, err := parseFormPDFInfo(ff.data)
+ if err != nil {
+ return err
+ }
+ fields, err := ff.fields()
+ if err != nil {
+ return err
+ }
+ objects := cloneFormObjects(info.objects)
+ pages := parseFormPageObjects(objects)
+ renderedByPage, textPages := collectFlattenedFieldContent(fields, pages)
+
+ omitObjects := flattenOmittedObjects(objects, info.rootObjNum)
+ err = removeFormCatalogAcroForm(objects, info.rootObjNum)
+ if err != nil {
+ return err
+ }
+ err = appendFlattenedPageStreams(objects, pages, renderedByPage, textPages)
+ if err != nil {
+ return err
+ }
+ err = removeFlattenedWidgetAnnotations(objects, pages, omitObjects)
+ if err != nil {
+ return err
+ }
+
+ ff.data = writeFormFullRewrite(info, objects, omitObjects)
+ return nil
+}
+
+func collectFlattenedFieldContent(fields []formFieldObject, pages map[int]formPageObject) (map[int][]string, map[int]bool) {
+ renderedByPage := make(map[int][]string)
+ textPages := make(map[int]bool)
+ for _, field := range fields {
+ pageObjNum, ok := formRefForKey(field.content, "P")
+ if !ok {
+ continue
+ }
+ if _, ok := pages[pageObjNum]; !ok {
+ continue
+ }
+ rect, ok := formRectForKey(field.content, "Rect")
+ if !ok {
+ continue
+ }
+ content, usesText := flattenFieldContent(field, rect)
+ if strings.TrimSpace(content) == "" {
+ continue
+ }
+ renderedByPage[pageObjNum] = append(renderedByPage[pageObjNum], content)
+ if usesText {
+ textPages[pageObjNum] = true
+ }
+ }
+ return renderedByPage, textPages
+}
+
+func removeFormCatalogAcroForm(objects map[int]formObject, rootObjNum int) error {
+ catalog, ok := objects[rootObjNum]
+ if !ok {
+ return fmt.Errorf("%w: catalog object %d missing", errFormNoRoot, rootObjNum)
+ }
+ objects[rootObjNum] = formObject{
+ content: removeFormDictionaryEntry(catalog.content, "AcroForm"),
+ }
+ return nil
+}
+
+func appendFlattenedPageStreams(
+ objects map[int]formObject,
+ pages map[int]formPageObject,
+ renderedByPage map[int][]string,
+ textPages map[int]bool,
+) error {
+ nextObjNum := maxFormObjectNumber(objects) + 1
+ for pageObjNum, streams := range renderedByPage {
+ page, ok := pages[pageObjNum]
+ if !ok {
+ continue
+ }
+ stream := strings.Join(streams, "\n")
+ streamObjNum := nextObjNum
+ nextObjNum++
+ objects[streamObjNum] = formObject{content: formStreamObject([]byte(stream))}
+
+ updatedPage, err := appendFormPageContent(page.content, streamObjNum)
+ if err != nil {
+ return err
+ }
+ objects[pageObjNum] = formObject{content: updatedPage}
+
+ if textPages[pageObjNum] && page.resourcesObjNum > 0 {
+ resources, ok := objects[page.resourcesObjNum]
+ if ok {
+ updatedResources, err := ensureFormHelvResource(resources.content)
+ if err != nil {
+ return err
+ }
+ objects[page.resourcesObjNum] = formObject{content: updatedResources}
+ }
+ }
+ }
+ return nil
+}
+
+func removeFlattenedWidgetAnnotations(
+ objects map[int]formObject,
+ pages map[int]formPageObject,
+ omitObjects map[int]bool,
+) error {
+ for pageObjNum, page := range pages {
+ content := page.content
+ if updated, ok := objects[pageObjNum]; ok {
+ content = updated.content
+ }
+ updatedPage, err := removeFormPageWidgetAnnots(content, omitObjects)
+ if err != nil {
+ return err
+ }
+ objects[pageObjNum] = formObject{content: updatedPage}
+ }
+ return nil
+}
+
+func (ff *FormFiller) field(name string) (formFieldObject, error) {
+ fields, err := ff.fields()
+ if err != nil {
+ return formFieldObject{}, err
+ }
+ for _, field := range fields {
+ if field.name == name {
+ return field, nil
+ }
+ }
+ return formFieldObject{}, fmt.Errorf("%w: %q", errFormFieldNotFound, name)
+}
+
+func (ff *FormFiller) fields() ([]formFieldObject, error) {
+ if ff == nil || len(ff.data) == 0 {
+ return nil, errNilFormReader
+ }
+ objects, _, err := parseFormObjects(ff.data)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int, 0, len(objects))
+ for id := range objects {
+ ids = append(ids, id)
+ }
+ sort.Ints(ids)
+
+ fields := make([]formFieldObject, 0)
+ for _, id := range ids {
+ content := objects[id].content
+ name, ok := formLiteralForKey(content, "T")
+ if !ok {
+ continue
+ }
+ fieldType := formNameForKey(content, "FT")
+ if fieldType == "" {
+ continue
+ }
+ fields = append(fields, formFieldObject{
+ objNum: id,
+ content: content,
+ name: name,
+ fieldType: fieldType,
+ value: formValueForKey(content, "V"),
+ exportName: checkboxExportName(content),
+ })
+ }
+ return fields, nil
+}
+
+func (ff *FormFiller) appendFieldUpdate(objNum int, content []byte) error {
+ info, err := parseFormPDFInfo(ff.data)
+ if err != nil {
+ return err
+ }
+ ff.data = writeFormIncrementalUpdate(info, objNum, content)
+ return nil
+}
+
+func parseFormPDFInfo(data []byte) (formPDFInfo, error) {
+ prevXref, err := findFormStartXref(data)
+ if err != nil {
+ return formPDFInfo{}, err
+ }
+ trailer, err := formTrailerBytes(data, prevXref)
+ if err != nil {
+ return formPDFInfo{}, err
+ }
+ objects, maxObjNum, err := parseFormObjects(data)
+ if err != nil {
+ return formPDFInfo{}, err
+ }
+ rootObjNum, err := parseFormRootObjectNumber(trailer)
+ if err != nil {
+ return formPDFInfo{}, err
+ }
+ return formPDFInfo{
+ data: append([]byte(nil), data...),
+ objects: objects,
+ rootObjNum: rootObjNum,
+ prevXref: prevXref,
+ size: parseFormTrailerSize(trailer, maxObjNum+1),
+ maxObjectNumber: maxObjNum,
+ infoRef: parseFormTrailerRef(trailer, formInfoRe),
+ idValue: parseFormTrailerRef(trailer, formIDRe),
+ }, nil
+}
+
+func parseFormObjects(data []byte) (map[int]formObject, int, error) {
+ matches := formObjectRe.FindAllSubmatch(data, -1)
+ if len(matches) == 0 {
+ return nil, 0, errFormNoObjects
+ }
+ objects := make(map[int]formObject, len(matches))
+ maxObjNum := 0
+ for _, match := range matches {
+ number, err := strconv.Atoi(string(match[1]))
+ if err != nil {
+ return nil, 0, fmt.Errorf("forms: invalid object number: %w", err)
+ }
+ if number > maxObjNum {
+ maxObjNum = number
+ }
+ objects[number] = formObject{content: append([]byte(nil), match[2]...)}
+ }
+ return objects, maxObjNum, nil
+}
+
+func cloneFormObjects(objects map[int]formObject) map[int]formObject {
+ cloned := make(map[int]formObject, len(objects))
+ for id, object := range objects {
+ cloned[id] = formObject{content: append([]byte(nil), object.content...)}
+ }
+ return cloned
+}
+
+func parseFormPageObjects(objects map[int]formObject) map[int]formPageObject {
+ pages := make(map[int]formPageObject)
+ for id, object := range objects {
+ if !isFormPageObject(object.content) {
+ continue
+ }
+ resourcesObjNum, _ := formRefForKey(object.content, "Resources")
+ pages[id] = formPageObject{
+ content: object.content,
+ resourcesObjNum: resourcesObjNum,
+ }
+ }
+ return pages
+}
+
+func isFormPageObject(content []byte) bool {
+ return regexp.MustCompile(`/Type\s*/Page\b`).Match(content)
+}
+
+func flattenFieldContent(field formFieldObject, rect [4]float64) (string, bool) {
+ switch field.fieldType {
+ case "Tx", "Ch":
+ if field.value == "" {
+ return "", false
+ }
+ return flattenTextFieldContent(field.value, rect), true
+ case "Btn":
+ return flattenCheckboxFieldContent(field.value != "" && field.value != formOffState, rect), false
+ default:
+ return "", false
+ }
+}
+
+func flattenTextFieldContent(value string, rect [4]float64) string {
+ x := rect[0] + 2
+ y := rect[1] + 4
+ fontSize := 10.0
+ height := rect[3] - rect[1]
+ if height > 0 {
+ fontSize = min(10.0, max(6.0, height-4))
+ y = rect[1] + max(2.0, (height-fontSize)/2)
+ }
+ return fmt.Sprintf("q\nBT\n/Helv %s Tf\n0 0 0 rg\n1 0 0 1 %s %s Tm\n%s Tj\nET\nQ",
+ formatFormFloat(fontSize),
+ formatFormFloat(x),
+ formatFormFloat(y),
+ pdfLiteralString(value))
+}
+
+func flattenCheckboxFieldContent(checked bool, rect [4]float64) string {
+ x := rect[0]
+ y := rect[1]
+ width := max(1.0, rect[2]-rect[0])
+ height := max(1.0, rect[3]-rect[1])
+ content := fmt.Sprintf("q\n0 0 0 RG\n0.7 w\n%s %s %s %s re S",
+ formatFormFloat(x), formatFormFloat(y), formatFormFloat(width), formatFormFloat(height))
+ if checked {
+ inset := min(width, height) * 0.2
+ content += fmt.Sprintf("\n%s %s m %s %s l %s %s m %s %s l S",
+ formatFormFloat(x+inset), formatFormFloat(y+inset),
+ formatFormFloat(x+width-inset), formatFormFloat(y+height-inset),
+ formatFormFloat(x+width-inset), formatFormFloat(y+inset),
+ formatFormFloat(x+inset), formatFormFloat(y+height-inset))
+ }
+ return content + "\nQ"
+}
+
+func flattenOmittedObjects(objects map[int]formObject, rootObjNum int) map[int]bool {
+ omit := seedFlattenOmittedObjects(objects, rootObjNum)
+ expandFlattenOmittedObjects(objects, omit)
+ return omit
+}
+
+func seedFlattenOmittedObjects(objects map[int]formObject, rootObjNum int) map[int]bool {
+ omit := make(map[int]bool)
+ if root, ok := objects[rootObjNum]; ok {
+ if acroFormObjNum, ok := formRefForKey(root.content, "AcroForm"); ok {
+ omit[acroFormObjNum] = true
+ }
+ }
+ for id, object := range objects {
+ if isFormFieldOrWidgetObject(object.content) {
+ omit[id] = true
+ }
+ }
+ return omit
+}
+
+func expandFlattenOmittedObjects(objects map[int]formObject, omit map[int]bool) {
+ changed := true
+ for changed {
+ changed = expandFlattenOmittedObjectsOnce(objects, omit)
+ }
+}
+
+func expandFlattenOmittedObjectsOnce(objects map[int]formObject, omit map[int]bool) bool {
+ changed := false
+ for id := range omit {
+ object, ok := objects[id]
+ if !ok {
+ continue
+ }
+ if addFlattenOmittedRefs(objects, omit, object.content) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+func addFlattenOmittedRefs(objects map[int]formObject, omit map[int]bool, content []byte) bool {
+ changed := false
+ for _, key := range []string{"Fields", "Kids", "AP"} {
+ if addFlattenOmittedRefsForKey(objects, omit, content, key) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+func addFlattenOmittedRefsForKey(objects map[int]formObject, omit map[int]bool, content []byte, key string) bool {
+ changed := false
+ for _, ref := range formRefsForDictionaryKey(content, key) {
+ if omit[ref] || !shouldOmitFormReference(objects, ref, key) {
+ continue
+ }
+ omit[ref] = true
+ changed = true
+ }
+ return changed
+}
+
+func shouldOmitFormReference(objects map[int]formObject, ref int, key string) bool {
+ if key == "AP" {
+ return true
+ }
+ refObj, ok := objects[ref]
+ return ok && isFormFieldOrWidgetObject(refObj.content)
+}
+
+func isFormFieldOrWidgetObject(content []byte) bool {
+ return bytes.Contains(content, []byte("/FT /")) ||
+ bytes.Contains(content, []byte("/Subtype /Widget"))
+}
+
+func formStreamObject(content []byte) []byte {
+ var out bytes.Buffer
+ fmt.Fprintf(&out, "<< /Length %d >>\nstream\n", len(content))
+ out.Write(content)
+ out.WriteString("\nendstream")
+ return out.Bytes()
+}
+
+func appendFormPageContent(pageContent []byte, streamObjNum int) ([]byte, error) {
+ idx := bytes.Index(pageContent, []byte("/Contents"))
+ ref := fmt.Sprintf("%d 0 R", streamObjNum)
+ if idx < 0 {
+ return setFormDictionaryEntry(pageContent, "Contents", ref)
+ }
+ valueStart := formSkipSpaces(pageContent, idx+len("/Contents"))
+ if valueStart >= len(pageContent) {
+ return nil, errFormBadDictionary
+ }
+ var value string
+ switch pageContent[valueStart] {
+ case '[':
+ valueEnd := formSkipArray(pageContent, valueStart)
+ existing := strings.TrimSpace(string(pageContent[valueStart+1 : valueEnd-1]))
+ if existing == "" {
+ value = "[" + ref + "]"
+ } else {
+ value = "[" + existing + " " + ref + "]"
+ }
+ default:
+ valueEnd, ok := formSkipIndirectRef(pageContent, valueStart)
+ if !ok {
+ return nil, errFormBadDictionary
+ }
+ existing := strings.TrimSpace(string(pageContent[valueStart:valueEnd]))
+ value = "[" + existing + " " + ref + "]"
+ }
+ return setFormDictionaryEntry(pageContent, "Contents", value)
+}
+
+func removeFormPageWidgetAnnots(pageContent []byte, omit map[int]bool) ([]byte, error) {
+ idx := bytes.Index(pageContent, []byte("/Annots"))
+ if idx < 0 {
+ return pageContent, nil
+ }
+ valueStart := formSkipSpaces(pageContent, idx+len("/Annots"))
+ if valueStart >= len(pageContent) || pageContent[valueStart] != '[' {
+ return pageContent, nil
+ }
+ valueEnd := formSkipArray(pageContent, valueStart)
+ refs := formRefsInBytes(pageContent[valueStart:valueEnd])
+ kept := make([]string, 0, len(refs))
+ for _, ref := range refs {
+ if !omit[ref] {
+ kept = append(kept, fmt.Sprintf("%d 0 R", ref))
+ }
+ }
+ if len(kept) == 0 {
+ return removeFormDictionaryEntry(pageContent, "Annots"), nil
+ }
+ return setFormDictionaryEntry(pageContent, "Annots", "["+strings.Join(kept, " ")+"]")
+}
+
+func ensureFormHelvResource(resources []byte) ([]byte, error) {
+ if bytes.Contains(resources, []byte("/Helv")) {
+ return resources, nil
+ }
+ helv := []byte(" /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> ")
+ fontIdx := bytes.Index(resources, []byte("/Font"))
+ if fontIdx >= 0 {
+ valueStart := formSkipSpaces(resources, fontIdx+len("/Font"))
+ if valueStart < len(resources) && bytes.HasPrefix(resources[valueStart:], []byte("<<")) {
+ valueEnd := formSkipBalanced(resources, valueStart, []byte("<<"), []byte(">>"))
+ if valueEnd >= valueStart+4 {
+ insertAt := valueEnd - len(">>")
+ out := make([]byte, 0, len(resources)+len(helv))
+ out = append(out, resources[:insertAt]...)
+ out = append(out, helv...)
+ out = append(out, resources[insertAt:]...)
+ return out, nil
+ }
+ }
+ }
+ end := bytes.LastIndex(resources, []byte(">>"))
+ if end < 0 {
+ return nil, errFormBadDictionary
+ }
+ fontDict := []byte(" /Font <<")
+ fontDict = append(fontDict, helv...)
+ fontDict = append(fontDict, []byte(">> ")...)
+ out := make([]byte, 0, len(resources)+len(fontDict))
+ out = append(out, resources[:end]...)
+ out = append(out, fontDict...)
+ out = append(out, resources[end:]...)
+ return out, nil
+}
+
+func writeFormFullRewrite(info formPDFInfo, objects map[int]formObject, omit map[int]bool) []byte {
+ ids := make([]int, 0, len(objects))
+ maxObjNum := 0
+ for id := range objects {
+ if id > maxObjNum {
+ maxObjNum = id
+ }
+ if !omit[id] {
+ ids = append(ids, id)
+ }
+ }
+ sort.Ints(ids)
+
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "%%PDF-%s\n", parseFormPDFVersion(info.data))
+ offsets := make(map[int]int, len(ids))
+ for _, id := range ids {
+ offsets[id] = buf.Len()
+ fmt.Fprintf(&buf, "%d 0 obj\n", id)
+ buf.Write(bytes.TrimSpace(objects[id].content))
+ buf.WriteString("\nendobj\n")
+ }
+
+ xrefOffset := buf.Len()
+ fmt.Fprintf(&buf, "xref\n0 %d\n", maxObjNum+1)
+ buf.WriteString("0000000000 65535 f \n")
+ for id := 1; id <= maxObjNum; id++ {
+ offset, ok := offsets[id]
+ if !ok {
+ buf.WriteString("0000000000 65535 f \n")
+ continue
+ }
+ fmt.Fprintf(&buf, "%010d 00000 n \n", offset)
+ }
+ fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root %d 0 R", maxObjNum+1, info.rootObjNum)
+ if info.infoRef != "" {
+ fmt.Fprintf(&buf, " /Info %s", info.infoRef)
+ }
+ if info.idValue != "" {
+ fmt.Fprintf(&buf, " /ID %s", info.idValue)
+ }
+ fmt.Fprintf(&buf, " >>\nstartxref\n%d\n%%%%EOF\n", xrefOffset)
+ return buf.Bytes()
+}
+
+func parseFormPDFVersion(data []byte) string {
+ match := regexp.MustCompile(`%PDF-(\d+\.\d+)`).FindSubmatch(data)
+ if len(match) != 2 {
+ return "1.3"
+ }
+ return string(match[1])
+}
+
+func maxFormObjectNumber(objects map[int]formObject) int {
+ maxObjNum := 0
+ for id := range objects {
+ if id > maxObjNum {
+ maxObjNum = id
+ }
+ }
+ return maxObjNum
+}
+
+func writeFormIncrementalUpdate(info formPDFInfo, objNum int, content []byte) []byte {
+ var buf bytes.Buffer
+ buf.Write(info.data)
+ if buf.Len() > 0 && buf.Bytes()[buf.Len()-1] != '\n' {
+ buf.WriteByte('\n')
+ }
+ offset := buf.Len()
+ fmt.Fprintf(&buf, "%d 0 obj\n", objNum)
+ buf.Write(bytes.TrimSpace(content))
+ buf.WriteString("\nendobj\n")
+
+ xrefOffset := buf.Len()
+ fmt.Fprintf(&buf, "xref\n%d 1\n%010d 00000 n \n", objNum, offset)
+ trailerSize := max(info.size, max(info.maxObjectNumber, objNum)+1)
+ fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root %d 0 R", trailerSize, info.rootObjNum)
+ if info.infoRef != "" {
+ fmt.Fprintf(&buf, " /Info %s", info.infoRef)
+ }
+ if info.idValue != "" {
+ fmt.Fprintf(&buf, " /ID %s", info.idValue)
+ }
+ fmt.Fprintf(&buf, " /Prev %d >>\nstartxref\n%d\n%%%%EOF\n", info.prevXref, xrefOffset)
+ return buf.Bytes()
+}
+
+func findFormStartXref(data []byte) (int64, error) {
+ searchLen := min(1024, len(data))
+ tail := data[len(data)-searchLen:]
+ idx := bytes.LastIndex(tail, []byte("startxref"))
+ if idx < 0 {
+ return 0, fmt.Errorf("%w: last %d bytes", errFormNoStartXref, searchLen)
+ }
+ after := strings.TrimSpace(string(tail[idx+len("startxref"):]))
+ if nl := strings.IndexAny(after, "\r\n"); nl > 0 {
+ after = after[:nl]
+ }
+ offset, err := strconv.ParseInt(strings.TrimSpace(after), 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("forms: invalid startxref offset: %w", err)
+ }
+ if offset < 0 || offset >= int64(len(data)) {
+ return 0, fmt.Errorf("%w: %d", errFormXrefOutOfBounds, offset)
+ }
+ return offset, nil
+}
+
+func formTrailerBytes(data []byte, xrefOffset int64) ([]byte, error) {
+ if xrefOffset < 0 || xrefOffset >= int64(len(data)) {
+ return nil, fmt.Errorf("%w: %d", errFormXrefOutOfBounds, xrefOffset)
+ }
+ segment := data[xrefOffset:]
+ trailerIdx := bytes.Index(segment, []byte("trailer"))
+ if trailerIdx < 0 {
+ return nil, errFormNoTrailer
+ }
+ start := trailerIdx + len("trailer")
+ end := bytes.Index(segment[start:], []byte("startxref"))
+ if end < 0 {
+ end = len(segment) - start
+ }
+ return bytes.TrimSpace(segment[start : start+end]), nil
+}
+
+func parseFormRootObjectNumber(trailer []byte) (int, error) {
+ match := formRootRe.FindSubmatch(trailer)
+ if len(match) != 2 {
+ return 0, errFormNoRoot
+ }
+ rootObjNum, err := strconv.Atoi(string(match[1]))
+ if err != nil {
+ return 0, fmt.Errorf("forms: invalid root reference: %w", err)
+ }
+ return rootObjNum, nil
+}
+
+func parseFormTrailerSize(trailer []byte, fallback int) int {
+ match := formSizeRe.FindSubmatch(trailer)
+ if len(match) != 2 {
+ return fallback
+ }
+ size, err := strconv.Atoi(string(match[1]))
+ if err != nil {
+ return fallback
+ }
+ return size
+}
+
+func parseFormTrailerRef(trailer []byte, re *regexp.Regexp) string {
+ match := re.FindSubmatch(trailer)
+ if len(match) != 2 {
+ return ""
+ }
+ return string(match[1])
+}
+
+func setFormDictionaryEntry(content []byte, key, value string) ([]byte, error) {
+ cleaned := removeFormDictionaryEntry(bytes.TrimSpace(content), key)
+ end := bytes.LastIndex(cleaned, []byte(">>"))
+ if end < 0 {
+ return nil, errFormBadDictionary
+ }
+ var out bytes.Buffer
+ out.Write(bytes.TrimRight(cleaned[:end], " \t\r\n"))
+ fmt.Fprintf(&out, " /%s %s ", key, value)
+ out.Write(bytes.TrimSpace(cleaned[end:]))
+ return out.Bytes(), nil
+}
+
+func removeFormDictionaryEntry(dictionary []byte, key string) []byte {
+ idx := formKeyIndex(dictionary, key)
+ if idx < 0 {
+ return dictionary
+ }
+ valueStart := formSkipSpaces(dictionary, idx+len(key)+1)
+ valueEnd := formSkipPDFValue(dictionary, valueStart)
+ if valueEnd <= valueStart {
+ return dictionary
+ }
+ out := make([]byte, 0, len(dictionary)-(valueEnd-idx))
+ out = append(out, bytes.TrimRight(dictionary[:idx], " \t\r\n")...)
+ out = append(out, ' ')
+ out = append(out, bytes.TrimLeft(dictionary[valueEnd:], " \t\r\n")...)
+ return out
+}
+
+func formLiteralForKey(content []byte, key string) (string, bool) {
+ idx := formKeyIndex(content, key)
+ if idx < 0 {
+ return "", false
+ }
+ start := formSkipSpaces(content, idx+len(key)+1)
+ if start >= len(content) || content[start] != '(' {
+ return "", false
+ }
+ value, _ := parseFormLiteral(content, start)
+ return value, true
+}
+
+func formRefForKey(content []byte, key string) (int, bool) {
+ idx := formKeyIndex(content, key)
+ if idx < 0 {
+ return 0, false
+ }
+ start := formSkipSpaces(content, idx+len(key)+1)
+ firstEnd := formSkipToken(content, start)
+ secondStart := formSkipSpaces(content, firstEnd)
+ secondEnd := formSkipToken(content, secondStart)
+ refStart := formSkipSpaces(content, secondEnd)
+ if start >= len(content) || refStart >= len(content) || content[refStart] != 'R' {
+ return 0, false
+ }
+ _, err := strconv.Atoi(string(content[secondStart:secondEnd]))
+ if err != nil {
+ return 0, false
+ }
+ ref, err := strconv.Atoi(string(content[start:firstEnd]))
+ if err != nil {
+ return 0, false
+ }
+ return ref, true
+}
+
+func formRectForKey(content []byte, key string) ([4]float64, bool) {
+ idx := formKeyIndex(content, key)
+ if idx < 0 {
+ return [4]float64{}, false
+ }
+ start := formSkipSpaces(content, idx+len(key)+1)
+ if start >= len(content) || content[start] != '[' {
+ return [4]float64{}, false
+ }
+ end := formSkipArray(content, start)
+ fields := strings.Fields(string(content[start+1 : end-1]))
+ if len(fields) < 4 {
+ return [4]float64{}, false
+ }
+ x1, err := strconv.ParseFloat(fields[0], 64)
+ if err != nil {
+ return [4]float64{}, false
+ }
+ y1, err := strconv.ParseFloat(fields[1], 64)
+ if err != nil {
+ return [4]float64{}, false
+ }
+ x2, err := strconv.ParseFloat(fields[2], 64)
+ if err != nil {
+ return [4]float64{}, false
+ }
+ y2, err := strconv.ParseFloat(fields[3], 64)
+ if err != nil {
+ return [4]float64{}, false
+ }
+ return [4]float64{x1, y1, x2, y2}, true
+}
+
+func formNameForKey(content []byte, key string) string {
+ re := regexp.MustCompile(`/` + regexp.QuoteMeta(key) + `\s*/([A-Za-z0-9_.#-]+)`)
+ match := re.FindSubmatch(content)
+ if len(match) != 2 {
+ return ""
+ }
+ return string(match[1])
+}
+
+func formValueForKey(content []byte, key string) string {
+ idx := formKeyIndex(content, key)
+ if idx < 0 {
+ return ""
+ }
+ start := formSkipSpaces(content, idx+len(key)+1)
+ if start >= len(content) {
+ return ""
+ }
+ switch content[start] {
+ case '(':
+ value, _ := parseFormLiteral(content, start)
+ return value
+ case '/':
+ end := formSkipToken(content, start+1)
+ return string(content[start+1 : end])
+ default:
+ return ""
+ }
+}
+
+func checkboxExportName(content []byte) string {
+ match := formAPNRe.FindSubmatch(content)
+ if len(match) != 2 {
+ return defaultCheckboxExportName
+ }
+ names := formNameRe.FindAllSubmatch(match[1], -1)
+ for _, name := range names {
+ if len(name) == 2 && string(name[1]) != formOffState {
+ return string(name[1])
+ }
+ }
+ return defaultCheckboxExportName
+}
+
+func formRefsForDictionaryKey(content []byte, key string) []int {
+ idx := formKeyIndex(content, key)
+ if idx < 0 {
+ return nil
+ }
+ start := formSkipSpaces(content, idx+len(key)+1)
+ end := formSkipPDFValue(content, start)
+ if end <= start {
+ return nil
+ }
+ return formRefsInBytes(content[start:end])
+}
+
+func formRefsInBytes(content []byte) []int {
+ re := regexp.MustCompile(`(\d+)\s+\d+\s+R`)
+ matches := re.FindAllSubmatch(content, -1)
+ refs := make([]int, 0, len(matches))
+ for _, match := range matches {
+ if len(match) != 2 {
+ continue
+ }
+ ref, err := strconv.Atoi(string(match[1]))
+ if err == nil {
+ refs = append(refs, ref)
+ }
+ }
+ return refs
+}
+
+func formKeyIndex(content []byte, key string) int {
+ marker := []byte("/" + key)
+ searchFrom := 0
+ for searchFrom < len(content) {
+ idx := bytes.Index(content[searchFrom:], marker)
+ if idx < 0 {
+ return -1
+ }
+ idx += searchFrom
+ after := idx + len(marker)
+ if after >= len(content) || !isFormNameChar(content[after]) {
+ return idx
+ }
+ searchFrom = after
+ }
+ return -1
+}
+
+func isFormNameChar(c byte) bool {
+ return (c >= 'A' && c <= 'Z') ||
+ (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') ||
+ c == '_' || c == '-' || c == '.' || c == '#'
+}
+
+func pdfLiteralString(value string) string {
+ value = strings.ReplaceAll(value, "\\", "\\\\")
+ value = strings.ReplaceAll(value, "(", "\\(")
+ value = strings.ReplaceAll(value, ")", "\\)")
+ return "(" + value + ")"
+}
+
+func formatFormFloat(v float64) string {
+ if v == float64(int(v)) {
+ return strconv.Itoa(int(v))
+ }
+ return strconv.FormatFloat(v, 'f', 2, 64)
+}
+
+func formSkipPDFValue(data []byte, start int) int {
+ start = formSkipSpaces(data, start)
+ if start >= len(data) {
+ return start
+ }
+ if bytes.HasPrefix(data[start:], []byte("<<")) {
+ return formSkipBalanced(data, start, []byte("<<"), []byte(">>"))
+ }
+ if end, ok := formSkipIndirectRef(data, start); ok {
+ return end
+ }
+ switch data[start] {
+ case '[':
+ return formSkipArray(data, start)
+ case '(':
+ _, end := parseFormLiteral(data, start)
+ return end
+ }
+ return formSkipToken(data, start)
+}
+
+func formSkipIndirectRef(data []byte, start int) (int, bool) {
+ start = formSkipSpaces(data, start)
+ firstEnd := formSkipToken(data, start)
+ if firstEnd <= start {
+ return start, false
+ }
+ _, err := strconv.Atoi(string(data[start:firstEnd]))
+ if err != nil {
+ return start, false
+ }
+ secondStart := formSkipSpaces(data, firstEnd)
+ secondEnd := formSkipToken(data, secondStart)
+ if secondEnd <= secondStart {
+ return start, false
+ }
+ _, err = strconv.Atoi(string(data[secondStart:secondEnd]))
+ if err != nil {
+ return start, false
+ }
+ refStart := formSkipSpaces(data, secondEnd)
+ if refStart >= len(data) || data[refStart] != 'R' {
+ return start, false
+ }
+ return refStart + 1, true
+}
+
+func formSkipBalanced(data []byte, start int, open, closing []byte) int {
+ depth := 0
+ for i := start; i < len(data); {
+ switch {
+ case bytes.HasPrefix(data[i:], open):
+ depth++
+ i += len(open)
+ case bytes.HasPrefix(data[i:], closing):
+ depth--
+ i += len(closing)
+ if depth == 0 {
+ return i
+ }
+ case data[i] == '(':
+ _, i = parseFormLiteral(data, i)
+ default:
+ i++
+ }
+ }
+ return len(data)
+}
+
+func formSkipArray(data []byte, start int) int {
+ depth := 0
+ for i := start; i < len(data); i++ {
+ switch data[i] {
+ case '[':
+ depth++
+ case ']':
+ depth--
+ if depth == 0 {
+ return i + 1
+ }
+ case '(':
+ _, i = parseFormLiteral(data, i)
+ i--
+ }
+ }
+ return len(data)
+}
+
+func parseFormLiteral(data []byte, start int) (string, int) {
+ var out strings.Builder
+ depth := 0
+ for i := start; i < len(data); i++ {
+ switch data[i] {
+ case '\\':
+ if i+1 < len(data) {
+ out.WriteByte(data[i+1])
+ i++
+ }
+ case '(':
+ depth++
+ if depth > 1 {
+ out.WriteByte('(')
+ }
+ case ')':
+ depth--
+ if depth == 0 {
+ return out.String(), i + 1
+ }
+ out.WriteByte(')')
+ default:
+ out.WriteByte(data[i])
+ }
+ }
+ return out.String(), len(data)
+}
+
+func formSkipSpaces(data []byte, start int) int {
+ for start < len(data) && isFormPDFSpace(data[start]) {
+ start++
+ }
+ return start
+}
+
+func formSkipToken(data []byte, start int) int {
+ i := start
+ for i < len(data) && !isFormPDFSpace(data[i]) && !isFormPDFDelimiter(data[i]) {
+ i++
+ }
+ if i == start && i < len(data) {
+ return i + 1
+ }
+ return i
+}
+
+func isFormPDFSpace(c byte) bool {
+ return c == 0 || c == '\t' || c == '\n' || c == '\f' || c == '\r' || c == ' '
+}
+
+func isFormPDFDelimiter(c byte) bool {
+ switch c {
+ case '(', ')', '<', '>', '[', ']', '{', '}', '/', '%':
+ return true
+ default:
+ return false
+ }
+}
diff --git a/pkg/forms/forms.go b/pkg/forms/forms.go
new file mode 100644
index 00000000..dfca6710
--- /dev/null
+++ b/pkg/forms/forms.go
@@ -0,0 +1,81 @@
+// Package forms provides AcroForm support for generated Paper PDFs.
+package forms
+
+import "github.com/avdoseferovic/paper/pkg/core/entity"
+
+type (
+ FieldType = entity.FieldType
+ FieldFlags = entity.FieldFlags
+ RadioOption = entity.RadioOption
+ Field = entity.Field
+ AcroForm = entity.AcroForm
+)
+
+const (
+ FieldText = entity.FieldText
+ FieldCheckbox = entity.FieldCheckbox
+ FieldRadio = entity.FieldRadio
+ FieldDropdown = entity.FieldDropdown
+ FieldListBox = entity.FieldListBox
+ FieldPushButton = entity.FieldPushButton
+ FieldSignature = entity.FieldSignature
+)
+
+const (
+ FlagReadOnly = entity.FlagReadOnly
+ FlagRequired = entity.FlagRequired
+ FlagNoExport = entity.FlagNoExport
+
+ FlagMultiline = entity.FlagMultiline
+ FlagPassword = entity.FlagPassword
+ FlagFileSelect = entity.FlagFileSelect
+ FlagDoNotSpellCheck = entity.FlagDoNotSpellCheck
+ FlagDoNotScroll = entity.FlagDoNotScroll
+ FlagComb = entity.FlagComb
+ FlagRichText = entity.FlagRichText
+
+ FlagCombo = entity.FlagCombo
+ FlagEdit = entity.FlagEdit
+ FlagSort = entity.FlagSort
+ FlagMultiSelect = entity.FlagMultiSelect
+
+ FlagNoToggleToOff = entity.FlagNoToggleToOff
+ FlagRadioFlag = entity.FlagRadioFlag
+ FlagPushButtonFlag = entity.FlagPushButtonFlag
+)
+
+func NewAcroForm() *AcroForm {
+ return entity.NewAcroForm()
+}
+
+func NewTextField(name string, rect [4]float64, pageIndex int) *Field {
+ return entity.NewTextField(name, rect, pageIndex)
+}
+
+func NewMultilineTextField(name string, rect [4]float64, pageIndex int) *Field {
+ return entity.NewMultilineTextField(name, rect, pageIndex)
+}
+
+func NewPasswordField(name string, rect [4]float64, pageIndex int) *Field {
+ return entity.NewPasswordField(name, rect, pageIndex)
+}
+
+func NewCheckbox(name string, rect [4]float64, pageIndex int, checked bool) *Field {
+ return entity.NewCheckbox(name, rect, pageIndex, checked)
+}
+
+func NewRadioGroup(name string, options []RadioOption) *Field {
+ return entity.NewRadioGroup(name, options)
+}
+
+func NewDropdown(name string, rect [4]float64, pageIndex int, options []string) *Field {
+ return entity.NewDropdown(name, rect, pageIndex, options)
+}
+
+func NewListBox(name string, rect [4]float64, pageIndex int, options []string) *Field {
+ return entity.NewListBox(name, rect, pageIndex, options)
+}
+
+func NewSignatureField(name string, rect [4]float64, pageIndex int) *Field {
+ return entity.NewSignatureField(name, rect, pageIndex)
+}
diff --git a/pkg/forms/forms_extra_test.go b/pkg/forms/forms_extra_test.go
new file mode 100644
index 00000000..c2925fa8
--- /dev/null
+++ b/pkg/forms/forms_extra_test.go
@@ -0,0 +1,61 @@
+package forms_test
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/avdoseferovic/paper"
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/components/col"
+ "github.com/avdoseferovic/paper/pkg/components/text"
+ "github.com/avdoseferovic/paper/pkg/config"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/forms"
+ "github.com/avdoseferovic/paper/pkg/reader"
+)
+
+func TestNewMultilineTextFieldSetsMultilineFlag(t *testing.T) {
+ t.Parallel()
+
+ field := forms.NewMultilineTextField("notes", [4]float64{72, 600, 300, 700}, 0)
+
+ assert.Equal(t, entity.FieldText, field.Type)
+ assert.True(t, field.Flags&entity.FlagMultiline != 0)
+}
+
+func TestNewPasswordFieldSetsPasswordFlag(t *testing.T) {
+ t.Parallel()
+
+ field := forms.NewPasswordField("secret", [4]float64{72, 600, 300, 620}, 0)
+
+ assert.Equal(t, entity.FieldText, field.Type)
+ assert.True(t, field.Flags&entity.FlagPassword != 0)
+}
+
+func TestFormFillerSaveTo(t *testing.T) {
+ t.Parallel()
+
+ form := forms.NewAcroForm().
+ Add(forms.NewTextField("name", [4]float64{72, 700, 300, 720}, 0).SetValue("Ada"))
+ cfg := config.NewBuilder().WithCompression(false).WithAcroForm(form).Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("SaveTo test")))
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+
+ r, err := reader.Parse(pdf.GetBytes())
+ require.NoError(t, err)
+ filler := forms.NewFormFiller(r)
+ require.NoError(t, filler.SetValue("name", "Grace"))
+
+ path := filepath.Join(t.TempDir(), "filled.pdf")
+ require.NoError(t, filler.SaveTo(path))
+
+ saved, err := os.ReadFile(path)
+ require.NoError(t, err)
+ assert.NotEmpty(t, saved)
+ assert.Contains(t, string(saved), "Grace")
+}
diff --git a/pkg/forms/forms_test.go b/pkg/forms/forms_test.go
new file mode 100644
index 00000000..4606ea63
--- /dev/null
+++ b/pkg/forms/forms_test.go
@@ -0,0 +1,230 @@
+package forms_test
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/avdoseferovic/paper"
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/components/col"
+ "github.com/avdoseferovic/paper/pkg/components/text"
+ "github.com/avdoseferovic/paper/pkg/config"
+ "github.com/avdoseferovic/paper/pkg/forms"
+ "github.com/avdoseferovic/paper/pkg/reader"
+)
+
+func TestAcroFormTextFieldGeneration(t *testing.T) {
+ t.Parallel()
+
+ form := forms.NewAcroForm().
+ Add(forms.NewTextField("name", [4]float64{72, 700, 300, 720}, 0).SetValue("John Doe"))
+
+ pdfBytes := generateFormPDF(t, form)
+
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/AcroForm")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/FT /Tx")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/Subtype /Widget")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/Annots [")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte(" 0 R ]")), "page annots should reference widget objects")
+ assert.True(t, bytes.Contains(pdfBytes, []byte("John Doe")))
+}
+
+func TestAcroFormCheckboxGeneration(t *testing.T) {
+ t.Parallel()
+
+ form := forms.NewAcroForm().
+ Add(forms.NewCheckbox("agree", [4]float64{72, 680, 92, 700}, 0, true)).
+ Add(forms.NewCheckbox("newsletter", [4]float64{72, 650, 92, 670}, 0, false))
+
+ pdfBytes := generateFormPDF(t, form)
+
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/FT /Btn")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/AS /Yes")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/AS /Off")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/AP")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("re S")), "checkbox appearance should draw a border")
+}
+
+func TestAcroFormChoiceRadioAndSignatureGeneration(t *testing.T) {
+ t.Parallel()
+
+ form := forms.NewAcroForm().
+ Add(forms.NewDropdown("country", [4]float64{72, 620, 250, 640}, 0,
+ []string{"USA", "Canada", "Mexico"}).SetValue("Canada")).
+ Add(forms.NewListBox("lang", [4]float64{72, 500, 250, 580}, 0,
+ []string{"Go", "Rust"})).
+ Add(forms.NewRadioGroup("size", []forms.RadioOption{
+ {Value: "S", Rect: [4]float64{72, 460, 92, 480}, PageIndex: 0},
+ {Value: "M", Rect: [4]float64{102, 460, 122, 480}, PageIndex: 0},
+ })).
+ Add(forms.NewSignatureField("sig", [4]float64{72, 100, 250, 150}, 0))
+
+ pdfBytes := generateFormPDF(t, form)
+ pdfText := string(pdfBytes)
+
+ assert.True(t, strings.Contains(pdfText, "/FT /Ch"))
+ assert.True(t, strings.Contains(pdfText, "/Opt"))
+ assert.True(t, strings.Contains(pdfText, "Canada"))
+ assert.True(t, strings.Contains(pdfText, "Rust"))
+ assert.True(t, strings.Contains(pdfText, "/Ff 131072"), "dropdown should carry combo-box flag")
+ assert.True(t, strings.Contains(pdfText, "/Kids"))
+ assert.True(t, strings.Contains(pdfText, "/S "))
+ assert.True(t, strings.Contains(pdfText, "/M "))
+ assert.GreaterOrEqual(t, strings.Count(pdfText, "/AS /Off"), 2)
+ assert.True(t, strings.Contains(pdfText, "/FT /Sig"))
+}
+
+func TestAcroFormRuntimeSetterClonesInput(t *testing.T) {
+ t.Parallel()
+
+ field := forms.NewTextField("name", [4]float64{72, 700, 300, 720}, 0).SetValue("Ada")
+ form := forms.NewAcroForm().Add(field)
+ cfg := config.NewBuilder().WithCompression(false).Build()
+ doc := paper.New(cfg)
+ doc.SetAcroForm(form)
+ field.Name = "mutated"
+ field.Value = "Grace"
+ doc.AddAutoRow(col.New(12).Add(text.New("Runtime form")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+ pdfBytes := pdf.GetBytes()
+
+ assert.True(t, bytes.Contains(pdfBytes, []byte("(name)")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("Ada")))
+ assert.False(t, bytes.Contains(pdfBytes, []byte("mutated")))
+ assert.False(t, bytes.Contains(pdfBytes, []byte("Grace")))
+}
+
+func TestAcroFormForcesWholeDocumentGeneration(t *testing.T) {
+ t.Parallel()
+
+ form := forms.NewAcroForm().
+ Add(forms.NewTextField("name", [4]float64{72, 700, 300, 720}, 0))
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithConcurrentMode(2).
+ WithAcroForm(form).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("Concurrent mode should be bypassed")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+
+ assert.True(t, bytes.Contains(pdf.GetBytes(), []byte("/AcroForm")))
+}
+
+func TestFormFillerReadsAndUpdatesGeneratedFields(t *testing.T) {
+ t.Parallel()
+
+ form := forms.NewAcroForm().
+ Add(forms.NewTextField("name", [4]float64{72, 700, 300, 720}, 0).SetValue("Ada")).
+ Add(forms.NewCheckbox("agree", [4]float64{72, 680, 92, 700}, 0, true)).
+ Add(forms.NewDropdown("role", [4]float64{72, 640, 250, 660}, 0,
+ []string{"Dev", "QA", "PM"}).SetValue("QA"))
+ r, err := reader.Parse(generateFormPDF(t, form))
+ require.NoError(t, err)
+
+ filler := forms.NewFormFiller(r)
+ names, err := filler.FieldNames()
+ require.NoError(t, err)
+ assert.Equal(t, []string{"name", "agree", "role"}, names)
+
+ value, err := filler.GetValue("agree")
+ require.NoError(t, err)
+ assert.Equal(t, "Yes", value)
+
+ require.NoError(t, filler.SetValue("name", "Grace"))
+ require.NoError(t, filler.SetValue("role", "PM"))
+ require.NoError(t, filler.SetCheckbox("agree", false))
+
+ value, err = filler.GetValue("name")
+ require.NoError(t, err)
+ assert.Equal(t, "Grace", value)
+ value, err = filler.GetValue("role")
+ require.NoError(t, err)
+ assert.Equal(t, "PM", value)
+ value, err = filler.GetValue("agree")
+ require.NoError(t, err)
+ assert.Equal(t, "Off", value)
+
+ out := filler.Bytes()
+ assert.True(t, bytes.Contains(out, []byte("Grace")))
+ assert.True(t, bytes.Contains(out, []byte("/AS /Off")))
+ _, err = reader.Parse(out)
+ require.NoError(t, err)
+}
+
+func TestFormFillerFlattenRendersValuesAndRemovesFormObjects(t *testing.T) {
+ t.Parallel()
+
+ form := forms.NewAcroForm().
+ Add(forms.NewTextField("name", [4]float64{72, 700, 300, 720}, 0).SetValue("Ada")).
+ Add(forms.NewCheckbox("agree", [4]float64{72, 680, 92, 700}, 0, true)).
+ Add(forms.NewDropdown("role", [4]float64{72, 640, 250, 660}, 0,
+ []string{"Dev", "QA", "PM"}).SetValue("QA"))
+ r, err := reader.Parse(generateFormPDF(t, form))
+ require.NoError(t, err)
+
+ filler := forms.NewFormFiller(r)
+ require.NoError(t, filler.SetValue("name", "Grace Hopper"))
+ require.NoError(t, filler.SetValue("role", "PM"))
+ require.NoError(t, filler.SetCheckbox("agree", false))
+
+ require.NoError(t, filler.Flatten())
+ out := filler.Bytes()
+
+ assert.False(t, bytes.Contains(out, []byte("/AcroForm")), "flattened bytes should not carry an AcroForm")
+ assert.False(t, bytes.Contains(out, []byte("/Subtype /Widget")), "flattened bytes should not carry widget annotations")
+ assert.False(t, bytes.Contains(out, []byte("/NeedAppearances")), "flattened bytes should not request viewer appearances")
+ assert.True(t, bytes.Contains(out, []byte("Grace Hopper")), "flattened page content should include filled text")
+ assert.True(t, bytes.Contains(out, []byte("(PM)")), "flattened page content should include filled choice")
+
+ parsed, err := reader.Parse(out)
+ require.NoError(t, err)
+ page, err := parsed.Page(0)
+ require.NoError(t, err)
+ content, err := page.ContentStream()
+ require.NoError(t, err)
+ assert.True(t, bytes.Contains(content, []byte("Grace Hopper")))
+ assert.True(t, bytes.Contains(content, []byte("(PM)")))
+
+ flattenedFiller := forms.NewFormFiller(parsed)
+ names, err := flattenedFiller.FieldNames()
+ require.NoError(t, err)
+ assert.Empty(t, names)
+}
+
+func TestFormFillerMissingFieldReturnsError(t *testing.T) {
+ t.Parallel()
+
+ form := forms.NewAcroForm().
+ Add(forms.NewTextField("name", [4]float64{72, 700, 300, 720}, 0))
+ r, err := reader.Parse(generateFormPDF(t, form))
+ require.NoError(t, err)
+ filler := forms.NewFormFiller(r)
+
+ _, err = filler.GetValue("missing")
+ assert.Error(t, err)
+ assert.Error(t, filler.SetValue("missing", "value"))
+ assert.Error(t, filler.SetCheckbox("missing", true))
+}
+
+func generateFormPDF(t *testing.T, form *forms.AcroForm) []byte {
+ t.Helper()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithAcroForm(form).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("Form document")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+ return pdf.GetBytes()
+}
diff --git a/pkg/html/css/bugfixes_test.go b/pkg/html/css/bugfixes_test.go
new file mode 100644
index 00000000..9721984d
--- /dev/null
+++ b/pkg/html/css/bugfixes_test.go
@@ -0,0 +1,185 @@
+package css
+
+import (
+ "math"
+ "testing"
+)
+
+func almostEqual(a, b float64) bool {
+ return math.Abs(a-b) < 0.01
+}
+
+func TestParseLength_RemResolves(t *testing.T) {
+ t.Parallel()
+
+ if got := ParseLength("1rem", 4); !almostEqual(got, 4) {
+ t.Errorf("1rem with parent 4mm = %v, want 4", got)
+ }
+ if got := ParseLength("2rem", 0); !almostEqual(got, 2*16*mmPerPx) {
+ t.Errorf("2rem with no parent = %v, want default-root fallback", got)
+ }
+ if got := ParseLength("2em", 4); !almostEqual(got, 8) {
+ t.Errorf("2em = %v, want 8", got)
+ }
+}
+
+func TestParseLength_InchUnit(t *testing.T) {
+ t.Parallel()
+
+ if got := ParseLength("1in", 0); !almostEqual(got, 25.4) {
+ t.Errorf("1in = %v, want 25.4", got)
+ }
+}
+
+func TestApplyFontSize_PercentAndKeywords(t *testing.T) {
+ t.Parallel()
+
+ s := NewComputedStyle()
+ s.Apply("font-size", "120%", &ComputedStyle{FontSize: 10})
+ if !almostEqual(s.FontSize, 12) {
+ t.Errorf("120%% of 10mm = %v, want 12", s.FontSize)
+ }
+
+ s = NewComputedStyle()
+ s.Apply("font-size", "larger", &ComputedStyle{FontSize: 10})
+ if !almostEqual(s.FontSize, 12) {
+ t.Errorf("larger of 10mm = %v, want 12", s.FontSize)
+ }
+
+ // Unparseable keyword must NOT clobber the inherited size with 0.
+ s = NewComputedStyle()
+ s.FontSize = 5
+ s.Apply("font-size", "bogus-keyword", &ComputedStyle{FontSize: 10})
+ if s.FontSize != 5 {
+ t.Errorf("bogus font-size clobbered inherited size: %v", s.FontSize)
+ }
+}
+
+func TestApplyLineHeight_UnitsBecomeMultipliers(t *testing.T) {
+ t.Parallel()
+
+ s := NewComputedStyle()
+ s.FontSize = 16 * mmPerPx // 16px
+ s.Apply("line-height", "24px", &ComputedStyle{FontSize: s.FontSize})
+ if !almostEqual(s.LineHeight, 1.5) {
+ t.Errorf("line-height 24px on 16px text = %v, want 1.5", s.LineHeight)
+ }
+
+ s = NewComputedStyle()
+ s.FontSize = 4
+ s.Apply("line-height", "150%", &ComputedStyle{FontSize: 4})
+ if !almostEqual(s.LineHeight, 1.5) {
+ t.Errorf("line-height 150%% = %v, want 1.5", s.LineHeight)
+ }
+
+ s = NewComputedStyle()
+ s.Apply("line-height", "1.6", &ComputedStyle{FontSize: 4})
+ if !almostEqual(s.LineHeight, 1.6) {
+ t.Errorf("unitless line-height = %v, want 1.6", s.LineHeight)
+ }
+
+ s = NewComputedStyle()
+ s.Apply("line-height", "normal", &ComputedStyle{FontSize: 4})
+ if !almostEqual(s.LineHeight, 1.0) {
+ t.Errorf("line-height normal = %v, want 1.0", s.LineHeight)
+ }
+}
+
+func TestParseColor_ModernSyntax(t *testing.T) {
+ t.Parallel()
+
+ c := ParseColor("rgb(255 0 0)")
+ if c == nil || c.R != 255 || c.G != 0 || c.B != 0 {
+ t.Errorf("rgb(255 0 0) = %+v, want red", c)
+ }
+
+ c = ParseColor("rgb(255 0 0 / 0.5)")
+ if c == nil || c.R != 255 {
+ t.Errorf("rgb slash alpha = %+v, want red", c)
+ }
+ if c != nil && !almostEqual(c.A, 0.5) {
+ t.Errorf("rgb slash alpha lost alpha: %+v", c)
+ }
+
+ c = ParseColor("hsl(120deg, 100%, 25%)")
+ if c == nil || c.G == 0 {
+ t.Errorf("hsl with deg = %+v, want green-ish", c)
+ }
+}
+
+func TestExpandShorthands_BorderColorFunctions(t *testing.T) {
+ t.Parallel()
+
+ out := ExpandShorthands(map[string]string{"border": "1px solid rgb(255, 0, 0)"})
+ if got := out["border-top-color"]; got != "rgb(255, 0, 0)" {
+ t.Errorf("border shorthand color = %q, want rgb(255, 0, 0)", got)
+ }
+
+ out = ExpandShorthands(map[string]string{"border": "solid red"})
+ if got := out["border-top-width"]; got != "medium" {
+ t.Errorf("border shorthand default width = %q, want medium", got)
+ }
+}
+
+func TestBorderWidthKeywords(t *testing.T) {
+ t.Parallel()
+
+ s := NewComputedStyle()
+ s.Apply("border-top-width", "medium", &ComputedStyle{FontSize: 4})
+ if !almostEqual(s.BorderTopWidth, 3*mmPerPx) {
+ t.Errorf("border-width medium = %v, want 3px", s.BorderTopWidth)
+ }
+ s.Apply("border-bottom-width", "thin", &ComputedStyle{FontSize: 4})
+ if !almostEqual(s.BorderBottomWidth, mmPerPx) {
+ t.Errorf("border-width thin = %v, want 1px", s.BorderBottomWidth)
+ }
+}
+
+func TestBorderWidthEmUsesFontSize(t *testing.T) {
+ t.Parallel()
+
+ s := NewComputedStyle()
+ s.FontSize = 4
+ s.Apply("border-top-width", "1em", &ComputedStyle{FontSize: 4})
+ if !almostEqual(s.BorderTopWidth, 4) {
+ t.Errorf("border-width 1em = %v, want 4", s.BorderTopWidth)
+ }
+}
+
+func TestHeightEmUsesFontSize(t *testing.T) {
+ t.Parallel()
+
+ s := NewComputedStyle()
+ s.FontSize = 4
+ s.Apply("height", "2em", &ComputedStyle{FontSize: 4})
+ if !almostEqual(s.Height, 8) {
+ t.Errorf("height 2em = %v, want 8", s.Height)
+ }
+}
+
+func TestExpandShorthands_MarginTooManyValuesIgnored(t *testing.T) {
+ t.Parallel()
+
+ out := ExpandShorthands(map[string]string{"margin": "1px 2px 3px 4px 5px"})
+ if _, ok := out["margin-top"]; ok {
+ t.Errorf("invalid margin shorthand should be dropped, got %v", out)
+ }
+}
+
+func TestExpandShorthands_FontWithLineHeight(t *testing.T) {
+ t.Parallel()
+
+ out := ExpandShorthands(map[string]string{"font": "italic bold 12px/1.5 Arial"})
+ if got := out["font-size"]; got != "12px" {
+ t.Errorf("font shorthand size = %q, want 12px", got)
+ }
+ if got := out["line-height"]; got != "1.5" {
+ t.Errorf("font shorthand line-height = %q, want 1.5", got)
+ }
+ if got := out["font-weight"]; got != "bold" {
+ t.Errorf("font shorthand weight = %q, want bold", got)
+ }
+ if got := out["font-style"]; got != "italic" {
+ t.Errorf("font shorthand style = %q, want italic", got)
+ }
+}
diff --git a/pkg/html/css/color.go b/pkg/html/css/color.go
index 895a5417..26f1fa22 100644
--- a/pkg/html/css/color.go
+++ b/pkg/html/css/color.go
@@ -113,10 +113,12 @@ func parseHex8(hex string) *RGBColor {
}
// parseRGBFunc parses the argument portion of rgb(…) — i.e. "255, 0, 0)".
+// An optional fourth (alpha) component is accepted for the modern
+// "rgb(255 0 0 / 0.5)" syntax.
func parseRGBFunc(args string) *RGBColor {
args = stripTrailingParen(args)
parts := splitCSSArgs(args)
- if len(parts) != 3 {
+ if len(parts) != 3 && len(parts) != 4 {
return nil
}
r, ok1 := parseColorChannel(parts[0])
@@ -125,7 +127,15 @@ func parseRGBFunc(args string) *RGBColor {
if !ok1 || !ok2 || !ok3 {
return nil
}
- return &RGBColor{R: clamp255(r), G: clamp255(g), B: clamp255(b), A: 1.0}
+ alpha := 1.0
+ if len(parts) == 4 {
+ a, ok := parseAlphaChannel(parts[3])
+ if !ok {
+ return nil
+ }
+ alpha = a
+ }
+ return &RGBColor{R: clamp255(r), G: clamp255(g), B: clamp255(b), A: alpha}
}
// parseRGBAFunc parses the argument portion of rgba(…).
@@ -145,39 +155,35 @@ func parseRGBAFunc(args string) *RGBColor {
return &RGBColor{R: clamp255(r), G: clamp255(g), B: clamp255(b), A: a}
}
-// parseHSLFunc parses hsl(hue, sat%, light%).
+// parseHSLFunc parses hsl(hue, sat%, light%) with an optional alpha and
+// optional angle unit on the hue.
func parseHSLFunc(args string) *RGBColor {
args = stripTrailingParen(args)
parts := splitCSSArgs(args)
- if len(parts) != 3 {
+ if len(parts) != 3 && len(parts) != 4 {
return nil
}
- h, ok1 := parseFloat(parts[0])
+ h, ok1 := parseHue(parts[0])
s, ok2 := parsePctOrFloat(parts[1])
l, ok3 := parsePctOrFloat(parts[2])
if !ok1 || !ok2 || !ok3 {
return nil
}
+ alpha := 1.0
+ if len(parts) == 4 {
+ a, ok := parseAlphaChannel(parts[3])
+ if !ok {
+ return nil
+ }
+ alpha = a
+ }
r, g, b := hslToRGB(h, s, l)
- return &RGBColor{R: r, G: g, B: b, A: 1.0}
+ return &RGBColor{R: r, G: g, B: b, A: alpha}
}
// parseHSLAFunc parses hsla(hue, sat%, light%, alpha).
func parseHSLAFunc(args string) *RGBColor {
- args = stripTrailingParen(args)
- parts := splitCSSArgs(args)
- if len(parts) != 4 {
- return nil
- }
- h, ok1 := parseFloat(parts[0])
- s, ok2 := parsePctOrFloat(parts[1])
- l, ok3 := parsePctOrFloat(parts[2])
- a, ok4 := parseAlphaChannel(parts[3])
- if !ok1 || !ok2 || !ok3 || !ok4 {
- return nil
- }
- r, g, b := hslToRGB(h, s, l)
- return &RGBColor{R: r, G: g, B: b, A: a}
+ return parseHSLFunc(args)
}
// hslToRGB converts HSL (h in [0,360), s and l in [0,1]) to RGB [0,255].
@@ -303,14 +309,42 @@ func stripTrailingParen(s string) string {
}
// splitCSSArgs splits "255, 0, 0" or "255,0,0" on commas, trimming whitespace.
+// splitCSSArgs splits a color-function argument list. It accepts both the
+// legacy comma syntax ("255, 0, 0, 0.5") and the modern space syntax with an
+// optional slash-separated alpha ("255 0 0 / 0.5").
func splitCSSArgs(s string) []string {
- parts := strings.Split(s, ",")
- out := make([]string, 0, len(parts))
- for _, p := range parts {
- p = strings.TrimSpace(p)
- if p != "" {
- out = append(out, p)
+ s = strings.ReplaceAll(s, "/", " ")
+ if strings.Contains(s, ",") {
+ parts := strings.Split(s, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
}
+ return out
+ }
+ return strings.Fields(s)
+}
+
+// parseHue parses a CSS hue component, accepting a plain number or the deg,
+// rad, grad, and turn angle units.
+func parseHue(s string) (float64, bool) {
+ s = strings.TrimSpace(strings.ToLower(s))
+ switch {
+ case strings.HasSuffix(s, "deg"):
+ return parseFloat(strings.TrimSuffix(s, "deg"))
+ case strings.HasSuffix(s, "grad"):
+ v, ok := parseFloat(strings.TrimSuffix(s, "grad"))
+ return v * 0.9, ok
+ case strings.HasSuffix(s, "rad"):
+ v, ok := parseFloat(strings.TrimSuffix(s, "rad"))
+ return v * 180 / math.Pi, ok
+ case strings.HasSuffix(s, "turn"):
+ v, ok := parseFloat(strings.TrimSuffix(s, "turn"))
+ return v * 360, ok
+ default:
+ return parseFloat(s)
}
- return out
}
diff --git a/pkg/html/css/computed.go b/pkg/html/css/computed.go
index fc81beda..9594d262 100644
--- a/pkg/html/css/computed.go
+++ b/pkg/html/css/computed.go
@@ -96,6 +96,27 @@ type ComputedStyle struct {
RowGap float64 // mm
ColumnGap float64 // mm
+ // Multi-column properties
+ ColumnCount int // 0 = auto
+ ColumnWidth float64 // mm; 0 = auto
+ ColumnSpan string // "all" | "none"
+ ColumnRuleWidth float64 // mm
+ ColumnRuleStyle string // "solid" | "dashed" | "dotted" | ...
+ ColumnRuleColor *RGBColor
+
+ // Grid container/item properties
+ GridTemplateColumns string
+ GridTemplateRows string
+ GridAutoFlow string
+ GridAutoRows string
+ GridTemplateAreas [][]string
+ GridArea string
+ GridColumnStart int // 0 = auto
+ GridColumnEnd int // 0 = auto
+ GridRowStart int // 0 = auto
+ GridRowEnd int // 0 = auto
+ JustifyItems string // "start" | "end" | "center" | "stretch"
+
// Flex item properties
FlexGrow float64 // default 0; used as proportional weight in layout
FlexShrink float64 // parsed/stored; no independent layout effect (quantizer prevents overflow)
@@ -128,6 +149,28 @@ type ComputedStyle struct {
PageBreakAfter string // "always" | "avoid" | "auto"
BreakInside string // "avoid" | "auto"
+ // Positioning. Position holds the CSS position keyword ("static",
+ // "relative", "absolute", "fixed", "sticky"); empty means unset. The box
+ // offsets are resolved lengths in mm (0 = unset/auto — CSS "auto" offsets
+ // are never stored). ZIndexSet distinguishes an explicit `z-index: 0` from
+ // the property being absent.
+ Position string
+ Top float64 // mm
+ Right float64 // mm
+ Bottom float64 // mm
+ Left float64 // mm
+ ZIndex int
+ ZIndexSet bool
+
+ // Transform holds the raw CSS transform list (e.g. "rotate(3deg) scale(2)")
+ // and TransformOrigin the raw transform-origin value; both are parsed by
+ // the consumer at render time so lengths can resolve against the box size.
+ Transform string
+ TransformOrigin string
+
+ // TextDecorationColor overrides the decoration line color; nil = currentColor.
+ TextDecorationColor *RGBColor
+
// CSS custom properties (--name: value). Stored as a flat map per element;
// cascade inheritance is handled by callers (computeNodeStyle) which copy
// from the parent's Vars map before applying child rules.
@@ -140,7 +183,7 @@ type ComputedStyle struct {
// Display defaults to "" (unset) — callers should treat "" the same as "block".
func NewComputedStyle() *ComputedStyle {
return &ComputedStyle{
- TextAlign: "left",
+ TextAlign: cssValueLeft,
FontWeight: "normal",
FontStyle: "normal",
Display: "",
@@ -208,6 +251,10 @@ func (s *ComputedStyle) ApplyCtx(prop, val string, parent *ComputedStyle, ctxWid
s.applyBoxProperty(ctx) ||
s.applyBorderProperty(ctx) ||
s.applyFlexProperty(ctx) ||
+ s.applyGridProperty(ctx) ||
+ s.applyColumnProperty(ctx) ||
+ s.applyPositionProperty(ctx) ||
+ s.applyTransformProperty(ctx) ||
s.applyTypographyProperty(ctx) {
return
}
diff --git a/pkg/html/css/computed_border.go b/pkg/html/css/computed_border.go
index 6cde96b4..4817e955 100644
--- a/pkg/html/css/computed_border.go
+++ b/pkg/html/css/computed_border.go
@@ -15,13 +15,13 @@ func (s *ComputedStyle) applyBorderProperty(ctx computedPropertyContext) bool {
case "outline":
parseOutlineShorthand(ctx.val, s, ctx.parentFontSize)
case "border-top-width":
- s.BorderTopWidth = ParseLength(ctx.val, 0)
+ s.BorderTopWidth = s.parseBorderWidth(ctx)
case "border-right-width":
- s.BorderRightWidth = ParseLength(ctx.val, 0)
+ s.BorderRightWidth = s.parseBorderWidth(ctx)
case "border-bottom-width":
- s.BorderBottomWidth = ParseLength(ctx.val, 0)
+ s.BorderBottomWidth = s.parseBorderWidth(ctx)
case "border-left-width":
- s.BorderLeftWidth = ParseLength(ctx.val, 0)
+ s.BorderLeftWidth = s.parseBorderWidth(ctx)
case "border-top-style":
s.BorderTopStyle = ctx.val
case "border-right-style":
@@ -42,20 +42,20 @@ func (s *ComputedStyle) applyBorderProperty(ctx computedPropertyContext) bool {
c := ParseColor(ctx.val)
s.BorderTopColor, s.BorderRightColor, s.BorderBottomColor, s.BorderLeftColor = c, c, c, c
case "border-width":
- w := ParseLength(ctx.val, 0)
+ w := s.parseBorderWidth(ctx)
s.BorderTopWidth, s.BorderRightWidth, s.BorderBottomWidth, s.BorderLeftWidth = w, w, w, w
case "border-style":
s.BorderTopStyle, s.BorderRightStyle, s.BorderBottomStyle, s.BorderLeftStyle = ctx.val, ctx.val, ctx.val, ctx.val
case "border-radius":
- s.BorderRadius = ParseLength(ctx.val, 0)
+ s.BorderRadius = ParseLength(ctx.val, s.borderFontSize(ctx))
case "border-top-left-radius":
- s.BorderRadiusTopLeft = ParseLength(ctx.val, 0)
+ s.BorderRadiusTopLeft = ParseLength(ctx.val, s.borderFontSize(ctx))
case "border-top-right-radius":
- s.BorderRadiusTopRight = ParseLength(ctx.val, 0)
+ s.BorderRadiusTopRight = ParseLength(ctx.val, s.borderFontSize(ctx))
case "border-bottom-left-radius":
- s.BorderRadiusBottomLeft = ParseLength(ctx.val, 0)
+ s.BorderRadiusBottomLeft = ParseLength(ctx.val, s.borderFontSize(ctx))
case "border-bottom-right-radius":
- s.BorderRadiusBottomRight = ParseLength(ctx.val, 0)
+ s.BorderRadiusBottomRight = ParseLength(ctx.val, s.borderFontSize(ctx))
default:
return false
}
@@ -84,3 +84,26 @@ func parseOutlineShorthand(val string, s *ComputedStyle, parentFontSize float64)
}
}
}
+
+// borderFontSize resolves the font size used for em-valued border lengths:
+// the element's own size when set, else the inherited parent size.
+func (s *ComputedStyle) borderFontSize(ctx computedPropertyContext) float64 {
+ if s.FontSize > 0 {
+ return s.FontSize
+ }
+ return ctx.parentFontSize
+}
+
+// parseBorderWidth resolves a border width, accepting the CSS width keywords
+// (thin/medium/thick as 1/3/5 px) alongside lengths.
+func (s *ComputedStyle) parseBorderWidth(ctx computedPropertyContext) float64 {
+ switch strings.ToLower(strings.TrimSpace(ctx.val)) {
+ case "thin":
+ return mmPerPx
+ case cssValueMedium:
+ return 3 * mmPerPx
+ case "thick":
+ return 5 * mmPerPx
+ }
+ return ParseLength(ctx.val, s.borderFontSize(ctx))
+}
diff --git a/pkg/html/css/computed_box.go b/pkg/html/css/computed_box.go
index 39859381..b7039b7c 100644
--- a/pkg/html/css/computed_box.go
+++ b/pkg/html/css/computed_box.go
@@ -39,15 +39,15 @@ func (s *ComputedStyle) applyBoxProperty(ctx computedPropertyContext) bool {
s.WidthAuto = false
}
case "height":
- s.Height = ParseLength(ctx.val, 0)
+ s.Height = ParseLength(ctx.val, s.borderFontSize(ctx))
case "min-width":
s.MinWidth = ParseLengthCtx(ctx.val, ctx.parentFontSize, ctx.ctxWidth)
case "max-width":
s.MaxWidth = ParseLengthCtx(ctx.val, ctx.parentFontSize, ctx.ctxWidth)
case "min-height":
- s.MinHeight = ParseLength(ctx.val, 0)
+ s.MinHeight = ParseLength(ctx.val, s.borderFontSize(ctx))
case "max-height":
- s.MaxHeight = ParseLength(ctx.val, 0)
+ s.MaxHeight = ParseLength(ctx.val, s.borderFontSize(ctx))
case "border-spacing":
s.applyBorderSpacing(ctx.val, ctx.parentFontSize)
case "object-fit":
diff --git a/pkg/html/css/computed_columns.go b/pkg/html/css/computed_columns.go
new file mode 100644
index 00000000..784795c6
--- /dev/null
+++ b/pkg/html/css/computed_columns.go
@@ -0,0 +1,223 @@
+package css
+
+import (
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+func (s *ComputedStyle) applyColumnProperty(ctx computedPropertyContext) bool {
+ switch ctx.prop {
+ case "column-count":
+ s.applyColumnCount(ctx.val)
+ case "column-width":
+ s.applyColumnWidth(ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ case "columns":
+ s.applyColumns(ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ case "column-span":
+ switch strings.ToLower(strings.TrimSpace(ctx.val)) {
+ case cssValueAll:
+ s.ColumnSpan = cssValueAll
+ case cssValueNone:
+ s.ColumnSpan = cssValueNone
+ }
+ case "column-gap":
+ if strings.EqualFold(strings.TrimSpace(ctx.val), "normal") {
+ return true
+ }
+ s.ColumnGap = ParseLengthCtx(ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ case "column-rule-width":
+ s.applyColumnRuleWidth(ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ case "column-rule-style":
+ s.applyColumnRuleStyle(ctx.val)
+ case "column-rule-color":
+ if c := ParseColor(ctx.val); c != nil {
+ s.ColumnRuleColor = c
+ }
+ case "column-rule":
+ s.applyColumnRule(ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ default:
+ return false
+ }
+ return true
+}
+
+func (s *ComputedStyle) applyColumnCount(value string) {
+ if strings.EqualFold(strings.TrimSpace(value), cssValueAuto) {
+ return
+ }
+ count, err := strconv.Atoi(strings.TrimSpace(value))
+ if err == nil && count > 0 {
+ s.ColumnCount = count
+ }
+}
+
+func (s *ComputedStyle) applyColumnWidth(value string, parentFontSize, ctxWidth float64) {
+ if strings.EqualFold(strings.TrimSpace(value), cssValueAuto) {
+ return
+ }
+ width := ParseLengthCtx(value, parentFontSize, ctxWidth)
+ if width > 0 {
+ s.ColumnWidth = width
+ }
+}
+
+func (s *ComputedStyle) applyColumns(value string, parentFontSize, ctxWidth float64) {
+ for token := range strings.FieldsSeq(value) {
+ if strings.EqualFold(token, cssValueAuto) {
+ continue
+ }
+ count, err := strconv.Atoi(token)
+ if err == nil && count > 0 {
+ s.ColumnCount = count
+ continue
+ }
+ if isColumnWidthToken(token) {
+ s.applyColumnWidth(token, parentFontSize, ctxWidth)
+ }
+ }
+}
+
+func isColumnWidthToken(token string) bool {
+ lower := strings.ToLower(strings.TrimSpace(token))
+ if lower == "" {
+ return false
+ }
+ if strings.HasPrefix(lower, "calc(") {
+ return true
+ }
+ for _, suffix := range []string{"mm", "cm", "in", "pt", "px", "em", "rem"} {
+ if strings.HasSuffix(lower, suffix) {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *ComputedStyle) applyColumnRuleWidth(value string, parentFontSize, ctxWidth float64) {
+ width, ok := parseColumnRuleWidth(value, parentFontSize, ctxWidth)
+ if ok {
+ s.ColumnRuleWidth = width
+ }
+}
+
+func (s *ComputedStyle) applyColumnRuleStyle(value string) {
+ if style := normalizeColumnRuleStyle(value); style != "" {
+ s.ColumnRuleStyle = style
+ }
+}
+
+func (s *ComputedStyle) applyColumnRule(value string, parentFontSize, ctxWidth float64) {
+ style := constColumnRuleSolid
+ var width float64
+ var color *RGBColor
+
+ for _, token := range splitTopLevelWhitespace(value) {
+ lower := strings.ToLower(strings.TrimSpace(token))
+ if nextStyle := normalizeColumnRuleStyle(lower); nextStyle != "" {
+ style = nextStyle
+ continue
+ }
+ if c := ParseColor(token); c != nil {
+ color = c
+ continue
+ }
+ if nextWidth, ok := parseColumnRuleWidth(lower, parentFontSize, ctxWidth); ok {
+ width = nextWidth
+ }
+ }
+
+ if style == cssValueNone || style == cssValueHidden {
+ width = 0
+ }
+ s.ColumnRuleWidth = width
+ s.ColumnRuleStyle = style
+ s.ColumnRuleColor = color
+}
+
+const constColumnRuleSolid = "solid"
+
+func normalizeColumnRuleStyle(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case constColumnRuleSolid, "dashed", "dotted", "double", cssValueNone, cssValueHidden,
+ "groove", "ridge", "inset", "outset":
+ return strings.ToLower(strings.TrimSpace(value))
+ default:
+ return ""
+ }
+}
+
+func parseColumnRuleWidth(value string, parentFontSize, ctxWidth float64) (float64, bool) {
+ lower := strings.ToLower(strings.TrimSpace(value))
+ switch lower {
+ case "thin":
+ return mmPerPx, true
+ case cssValueMedium:
+ return 3 * mmPerPx, true
+ case "thick":
+ return 5 * mmPerPx, true
+ case "":
+ return 0, false
+ }
+ width := ParseLengthCtx(lower, parentFontSize, ctxWidth)
+ if width != 0 || isZeroColumnRuleWidth(lower) {
+ return width, true
+ }
+ return 0, false
+}
+
+func isZeroColumnRuleWidth(value string) bool {
+ if value == "0" {
+ return true
+ }
+ for _, suffix := range []string{"mm", "cm", "in", "pt", "px", "em", "rem", "%"} {
+ num, ok := strings.CutSuffix(value, suffix)
+ if !ok {
+ continue
+ }
+ parsed, err := strconv.ParseFloat(strings.TrimSpace(num), 64)
+ return err == nil && parsed == 0
+ }
+ return false
+}
+
+func splitTopLevelWhitespace(value string) []string {
+ var tokens []string
+ var b strings.Builder
+ depth := 0
+ var quote rune
+ flush := func() {
+ token := strings.TrimSpace(b.String())
+ if token != "" {
+ tokens = append(tokens, token)
+ }
+ b.Reset()
+ }
+
+ for _, r := range value {
+ switch {
+ case quote != 0:
+ b.WriteRune(r)
+ if r == quote {
+ quote = 0
+ }
+ case r == '\'' || r == '"':
+ quote = r
+ b.WriteRune(r)
+ case r == '(':
+ depth++
+ b.WriteRune(r)
+ case r == ')':
+ if depth > 0 {
+ depth--
+ }
+ b.WriteRune(r)
+ case depth == 0 && unicode.IsSpace(r):
+ flush()
+ default:
+ b.WriteRune(r)
+ }
+ }
+ flush()
+ return tokens
+}
diff --git a/pkg/html/css/computed_effects.go b/pkg/html/css/computed_effects.go
index 691deab2..113704db 100644
--- a/pkg/html/css/computed_effects.go
+++ b/pkg/html/css/computed_effects.go
@@ -26,6 +26,10 @@ func (s *ComputedStyle) applyEffectsProperty(ctx computedPropertyContext) bool {
} else if s.unsupportedHandler != nil {
s.unsupportedHandler(ctx.prop, ctx.val)
}
+ case "text-decoration-color":
+ if c := ParseColor(ctx.val); c != nil {
+ s.TextDecorationColor = c
+ }
case "background-image":
s.applyBackgroundImage(ctx)
case "background-size":
diff --git a/pkg/html/css/computed_font.go b/pkg/html/css/computed_font.go
index f60e2eb2..d7f38386 100644
--- a/pkg/html/css/computed_font.go
+++ b/pkg/html/css/computed_font.go
@@ -1,13 +1,18 @@
package css
-import "strings"
+import (
+ "strconv"
+ "strings"
+)
func (s *ComputedStyle) applyFontProperty(ctx computedPropertyContext) bool {
switch ctx.prop {
case "font-family":
s.FontFamily = strings.Trim(ctx.val, `'"`)
case "font-size":
- s.FontSize = ParseLength(ctx.val, ctx.parentFontSize)
+ if size, ok := parseFontSize(ctx.val, ctx.parentFontSize); ok {
+ s.FontSize = size
+ }
case "font-weight":
s.FontWeight = normFontWeight(ctx.val)
case "font-style":
@@ -17,17 +22,90 @@ func (s *ComputedStyle) applyFontProperty(ctx computedPropertyContext) bool {
case "text-decoration":
s.TextDecoration = ctx.val
case "line-height":
- s.LineHeight = ParseLength(ctx.val, s.FontSize)
+ if multiplier, ok := parseLineHeight(ctx.val, s.FontSize, ctx.parentFontSize); ok {
+ s.LineHeight = multiplier
+ }
default:
return false
}
return true
}
+// parseFontSize resolves a font-size value in mm, handling percentages and
+// the CSS absolute/relative size keywords. Unparseable values report !ok so
+// the inherited size is preserved instead of collapsing to 0.
+func parseFontSize(value string, parentFontSize float64) (float64, bool) {
+ value = strings.ToLower(strings.TrimSpace(value))
+ base := parentFontSize
+ if base == 0 {
+ base = defaultRemMM
+ }
+ if frac, ok := ParsePercentage(value); ok {
+ return base * frac, frac > 0
+ }
+ // Keyword factors follow the browser px equivalents relative to 16px.
+ switch value {
+ case "xx-small":
+ return base * 9 / 16, true
+ case "x-small":
+ return base * 10 / 16, true
+ case "small":
+ return base * 13 / 16, true
+ case cssValueMedium:
+ return base, true
+ case "large":
+ return base * 18 / 16, true
+ case "x-large":
+ return base * 24 / 16, true
+ case "xx-large":
+ return base * 32 / 16, true
+ case "smaller":
+ return base * 0.833, true
+ case "larger":
+ return base * 1.2, true
+ }
+ size := ParseLength(value, parentFontSize)
+ if size <= 0 {
+ return 0, false
+ }
+ return size, true
+}
+
+// parseLineHeight resolves a line-height value as a multiplier of the font
+// size. Unitless numbers pass through; percentages and lengths convert to a
+// multiplier so downstream layout (which expects a factor) stays correct.
+func parseLineHeight(value string, fontSize, parentFontSize float64) (float64, bool) {
+ value = strings.TrimSpace(value)
+ if strings.EqualFold(value, "normal") {
+ return 1.0, true
+ }
+ if frac, ok := ParsePercentage(value); ok {
+ return frac, frac > 0
+ }
+ unitless, err := strconv.ParseFloat(value, 64)
+ if err == nil {
+ return unitless, unitless > 0
+ }
+ base := fontSize
+ if base == 0 {
+ base = parentFontSize
+ }
+ if base == 0 {
+ base = defaultRemMM
+ }
+ length := ParseLength(value, base)
+ if length <= 0 {
+ return 0, false
+ }
+ return length / base, true
+}
+
+const cssValueBold = "bold"
+
func normFontWeight(val string) string {
switch strings.ToLower(strings.TrimSpace(val)) {
- case "bold", "bolder", "700", "800", "900":
- return "bold"
+ case cssValueBold, "bolder", "700", "800", "900":
+ return cssValueBold
case "500", "600":
return strings.TrimSpace(val)
default:
diff --git a/pkg/html/css/computed_grid.go b/pkg/html/css/computed_grid.go
new file mode 100644
index 00000000..0626cce1
--- /dev/null
+++ b/pkg/html/css/computed_grid.go
@@ -0,0 +1,143 @@
+package css
+
+import (
+ "strconv"
+ "strings"
+)
+
+func (s *ComputedStyle) applyGridProperty(ctx computedPropertyContext) bool {
+ switch ctx.prop {
+ case "grid-template-columns":
+ s.GridTemplateColumns = strings.ToLower(strings.TrimSpace(ctx.val))
+ case "grid-template-rows":
+ s.GridTemplateRows = strings.ToLower(strings.TrimSpace(ctx.val))
+ case "grid-auto-flow":
+ s.GridAutoFlow = strings.ToLower(strings.TrimSpace(ctx.val))
+ case "grid-auto-rows":
+ s.GridAutoRows = strings.ToLower(strings.TrimSpace(ctx.val))
+ case "grid-template-areas":
+ s.GridTemplateAreas = parseGridTemplateAreas(ctx.val)
+ case "grid-area":
+ s.GridArea = strings.TrimSpace(ctx.val)
+ case "justify-items":
+ if align := normalizeGridItemAlign(ctx.val); align != "" {
+ s.JustifyItems = align
+ }
+ case "grid-column":
+ s.GridColumnStart, s.GridColumnEnd = parseGridLine(ctx.val)
+ case "grid-row":
+ s.GridRowStart, s.GridRowEnd = parseGridLine(ctx.val)
+ case "grid-column-start":
+ if v, ok := parseGridLineNumber(ctx.val); ok {
+ s.GridColumnStart = v
+ }
+ case "grid-column-end":
+ if v, ok := parseGridLineNumber(ctx.val); ok {
+ s.GridColumnEnd = v
+ }
+ case "grid-row-start":
+ if v, ok := parseGridLineNumber(ctx.val); ok {
+ s.GridRowStart = v
+ }
+ case "grid-row-end":
+ if v, ok := parseGridLineNumber(ctx.val); ok {
+ s.GridRowEnd = v
+ }
+ default:
+ return false
+ }
+ return true
+}
+
+func normalizeGridItemAlign(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case cssValueStart, cssValueEnd, cssValueCenter, cssValueStretch:
+ return strings.ToLower(strings.TrimSpace(value))
+ default:
+ return ""
+ }
+}
+
+func parseGridTemplateAreas(value string) [][]string {
+ value = strings.TrimSpace(value)
+ if value == "" || strings.EqualFold(value, cssValueNone) {
+ return nil
+ }
+ var areas [][]string
+ for i := 0; i < len(value); {
+ qStart := -1
+ for j := i; j < len(value); j++ {
+ if value[j] == '"' || value[j] == '\'' {
+ qStart = j
+ break
+ }
+ }
+ if qStart < 0 {
+ break
+ }
+ quote := value[qStart]
+ qEnd := -1
+ for j := qStart + 1; j < len(value); j++ {
+ if value[j] == quote {
+ qEnd = j
+ break
+ }
+ }
+ if qEnd < 0 {
+ break
+ }
+ if row := strings.Fields(strings.TrimSpace(value[qStart+1 : qEnd])); len(row) > 0 {
+ areas = append(areas, row)
+ }
+ i = qEnd + 1
+ }
+ return areas
+}
+
+func parseGridLine(value string) (int, int) {
+ parts := strings.Split(strings.TrimSpace(value), "/")
+ if len(parts) == 1 {
+ return parseSingleGridLine(parts[0])
+ }
+ start, _ := parseGridLineNumber(parts[0])
+ end, _ := parseGridLineEnd(parts[1], start)
+ return start, end
+}
+
+func parseSingleGridLine(value string) (int, int) {
+ value = strings.TrimSpace(strings.ToLower(value))
+ if strings.HasPrefix(value, "span") {
+ if span, ok := parseSpanCount(value); ok {
+ return 0, span
+ }
+ return 0, 0
+ }
+ start, _ := parseGridLineNumber(value)
+ return start, 0
+}
+
+func parseGridLineEnd(value string, start int) (int, bool) {
+ value = strings.TrimSpace(strings.ToLower(value))
+ if strings.HasPrefix(value, "span") {
+ span, ok := parseSpanCount(value)
+ if !ok {
+ return 0, false
+ }
+ if start > 0 {
+ return start + span, true
+ }
+ return span, true
+ }
+ return parseGridLineNumber(value)
+}
+
+func parseSpanCount(value string) (int, bool) {
+ count := strings.TrimSpace(strings.TrimPrefix(value, "span"))
+ v, err := strconv.Atoi(count)
+ return v, err == nil && v > 0
+}
+
+func parseGridLineNumber(value string) (int, bool) {
+ v, err := strconv.Atoi(strings.TrimSpace(value))
+ return v, err == nil && v > 0
+}
diff --git a/pkg/html/css/computed_position.go b/pkg/html/css/computed_position.go
new file mode 100644
index 00000000..6b58800f
--- /dev/null
+++ b/pkg/html/css/computed_position.go
@@ -0,0 +1,55 @@
+package css
+
+import (
+ "strconv"
+ "strings"
+)
+
+func (s *ComputedStyle) applyPositionProperty(ctx computedPropertyContext) bool {
+ switch ctx.prop {
+ case "position":
+ s.applyPosition(ctx.val)
+ case "top":
+ s.applyPositionOffset(&s.Top, ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ case cssValueRight:
+ s.applyPositionOffset(&s.Right, ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ case "bottom":
+ s.applyPositionOffset(&s.Bottom, ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ case cssValueLeft:
+ s.applyPositionOffset(&s.Left, ctx.val, ctx.parentFontSize, ctx.ctxWidth)
+ case "z-index":
+ s.applyZIndex(ctx.val)
+ default:
+ return false
+ }
+ return true
+}
+
+func (s *ComputedStyle) applyPosition(value string) {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "static", "relative", "absolute", "fixed", "sticky":
+ s.Position = strings.ToLower(strings.TrimSpace(value))
+ }
+}
+
+// applyPositionOffset resolves a top/right/bottom/left declaration. CSS "auto"
+// keeps the zero value, matching consumers that treat 0 as unset.
+func (s *ComputedStyle) applyPositionOffset(target *float64, value string, parentFontSize, ctxWidth float64) {
+ if strings.EqualFold(strings.TrimSpace(value), cssValueAuto) {
+ return
+ }
+ *target = ParseLengthCtx(value, parentFontSize, ctxWidth)
+}
+
+func (s *ComputedStyle) applyZIndex(value string) {
+ value = strings.TrimSpace(value)
+ if strings.EqualFold(value, cssValueAuto) {
+ return
+ }
+ z, err := strconv.Atoi(value)
+ if err != nil {
+ return
+ }
+ s.ZIndex = z
+ s.ZIndexSet = true
+}
diff --git a/pkg/html/css/computed_transform.go b/pkg/html/css/computed_transform.go
new file mode 100644
index 00000000..4c01bf15
--- /dev/null
+++ b/pkg/html/css/computed_transform.go
@@ -0,0 +1,15 @@
+package css
+
+import "strings"
+
+func (s *ComputedStyle) applyTransformProperty(ctx computedPropertyContext) bool {
+ switch ctx.prop {
+ case "transform":
+ s.Transform = strings.TrimSpace(ctx.val)
+ case "transform-origin":
+ s.TransformOrigin = strings.TrimSpace(ctx.val)
+ default:
+ return false
+ }
+ return true
+}
diff --git a/pkg/html/css/computed_typography.go b/pkg/html/css/computed_typography.go
index 1b2b2747..4698fbb3 100644
--- a/pkg/html/css/computed_typography.go
+++ b/pkg/html/css/computed_typography.go
@@ -40,7 +40,7 @@ func (s *ComputedStyle) applyTypographyProperty(ctx computedPropertyContext) boo
func normalizePageBreakValue(value string) string {
v := strings.ToLower(strings.TrimSpace(value))
switch v {
- case "page", "left", "right", "recto", "verso":
+ case "page", cssValueLeft, cssValueRight, "recto", "verso":
// Modern break-before/break-after use "page"; legacy page-break-* also
// allowed left/right. Paper does not choose page parity, so all of these
// map to the same hard page break marker.
diff --git a/pkg/html/css/constants.go b/pkg/html/css/constants.go
index 7e62e208..22b91698 100644
--- a/pkg/html/css/constants.go
+++ b/pkg/html/css/constants.go
@@ -1,6 +1,15 @@
package css
const (
- cssValueAuto = "auto"
- cssValueNone = "none"
+ cssValueAuto = "auto"
+ cssValueNone = "none"
+ cssValueAll = "all"
+ cssValueHidden = "hidden"
+ cssValueMedium = "medium"
+ cssValueStart = "start"
+ cssValueEnd = "end"
+ cssValueCenter = "center"
+ cssValueLeft = "left"
+ cssValueRight = "right"
+ cssValueStretch = "stretch"
)
diff --git a/pkg/html/css/coverage_extra_test.go b/pkg/html/css/coverage_extra_test.go
index 7d34c98f..311b4680 100644
--- a/pkg/html/css/coverage_extra_test.go
+++ b/pkg/html/css/coverage_extra_test.go
@@ -1062,12 +1062,21 @@ func TestParseColor_HSLEdgeCases(t *testing.T) {
"hsl(abc, 50%, 50%)",
"hsl(0, x%, 50%)",
"hsl(0, 50%, x%)",
- "hsla(0, 100%, 50%)",
"hsla(0, 100%, 50%, zz)",
} {
assert.Nil(t, ParseColor(in), "input %q", in)
}
})
+
+ // SEMANTICS CHANGE: per CSS Color 4, hsla() is an alias of hsl() and the
+ // alpha component is optional, so a 3-argument hsla() is valid (it used to
+ // be rejected here).
+ t.Run("hsla without alpha is valid", func(t *testing.T) {
+ t.Parallel()
+ c := ParseColor("hsla(0, 100%, 50%)")
+ require.NotNil(t, c)
+ assert.Equal(t, 255, c.R)
+ })
}
func TestParseColor_HexEdgeCases(t *testing.T) {
@@ -1197,7 +1206,6 @@ func TestExpandOne_AllShorthands(t *testing.T) {
{"1mm 2mm", "1mm", "2mm", "1mm", "2mm"},
{"1mm 2mm 3mm", "1mm", "2mm", "3mm", "2mm"},
{"1mm 2mm 3mm 4mm", "1mm", "2mm", "3mm", "4mm"},
- {"1mm 2mm 3mm 4mm 5mm", "0", "0", "0", "0"},
}
for _, tc := range cases {
out := expandOne("padding", tc.val)
@@ -1206,6 +1214,11 @@ func TestExpandOne_AllShorthands(t *testing.T) {
assert.Equal(t, tc.bottom, out["padding-bottom"], "val %q", tc.val)
assert.Equal(t, tc.left, out["padding-left"], "val %q", tc.val)
}
+ // SEMANTICS CHANGE: a malformed shorthand (5+ values) is now dropped
+ // per the CSS ignore-invalid-declarations rule instead of zeroing all
+ // four sides.
+ out5 := expandOne("padding", "1mm 2mm 3mm 4mm 5mm")
+ assert.Equal(t, "", out5["padding-top"])
out := expandOne("margin", "7mm")
assert.Equal(t, "7mm", out["margin-top"])
})
diff --git a/pkg/html/css/gradient.go b/pkg/html/css/gradient.go
index 88f3309c..d3577867 100644
--- a/pkg/html/css/gradient.go
+++ b/pkg/html/css/gradient.go
@@ -222,9 +222,9 @@ func parseRadialPosition(pos string) (float64, float64) {
return 0.5, 0.0
case "bottom":
return 0.5, 1.0
- case "left":
+ case cssValueLeft:
return 0.0, 0.5
- case "right":
+ case cssValueRight:
return 1.0, 0.5
case "top left", "left top":
return 0.0, 0.0
diff --git a/pkg/html/css/length.go b/pkg/html/css/length.go
index 3682a695..9c802540 100644
--- a/pkg/html/css/length.go
+++ b/pkg/html/css/length.go
@@ -9,6 +9,10 @@ const (
mmPerPx = 0.264583
mmPerPt = 0.352778
mmPerCm = 10.0
+ mmPerIn = 25.4
+ // defaultRemMM approximates 1rem (16px root font size) when a value uses
+ // rem with no root context available.
+ defaultRemMM = 16 * mmPerPx
)
// ParsePercentage parses a CSS percentage value (e.g. "25%") and returns
@@ -44,16 +48,24 @@ func ParseLength(value string, parentFontSize float64) float64 {
return v
}
+ remFactor := parentFontSize
+ if remFactor == 0 {
+ remFactor = defaultRemMM
+ }
+ // Longer suffixes must be checked before their suffixes ("rem" before
+ // "em", "mm"/"cm" before "m"): "1rem" also ends in "em" and must not be
+ // rejected because "1r" fails to parse.
units := []struct {
suffix string
factor float64
}{
+ {"rem", remFactor}, // approximate: rem resolves like em against the parent
{"mm", 1},
{"cm", mmPerCm},
{"pt", mmPerPt},
{"px", mmPerPx},
+ {"in", mmPerIn},
{"em", parentFontSize},
- {"rem", parentFontSize}, // approximate: treat rem same as em
}
for _, u := range units {
@@ -61,9 +73,9 @@ func ParseLength(value string, parentFontSize float64) float64 {
if !ok {
continue
}
- num, err := strconv.ParseFloat(numStr, 64)
+ num, err := strconv.ParseFloat(strings.TrimSpace(numStr), 64)
if err != nil {
- return 0
+ continue
}
return num * u.factor
}
diff --git a/pkg/html/css/shorthand.go b/pkg/html/css/shorthand.go
index 898277e8..ced778e5 100644
--- a/pkg/html/css/shorthand.go
+++ b/pkg/html/css/shorthand.go
@@ -43,11 +43,11 @@ func expandOne(prop, val string) map[string]string {
case "border-top":
return expandBorderSide("top", val)
case "border-right":
- return expandBorderSide("right", val)
+ return expandBorderSide(cssValueRight, val)
case "border-bottom":
return expandBorderSide("bottom", val)
case "border-left":
- return expandBorderSide("left", val)
+ return expandBorderSide(cssValueLeft, val)
case "border-radius":
return expandBorderRadius(val)
case "padding":
@@ -222,7 +222,7 @@ func hasTopLevelComma(value string) bool {
func expandBorderAll(val string) map[string]string {
width, style, color := parseBorderTriple(val)
out := make(map[string]string, 12)
- for _, side := range []string{"top", "right", "bottom", "left"} {
+ for _, side := range []string{"top", cssValueRight, "bottom", cssValueLeft} {
out["border-"+side+"-width"] = width
out["border-"+side+"-style"] = style
out["border-"+side+"-color"] = color
@@ -241,27 +241,31 @@ func expandBorderSide(side, val string) map[string]string {
}
// parseBorderTriple splits a "1px solid red" border shorthand into its three parts.
-// Parts may be in any order (width=has unit, style=keyword, color=otherwise).
+// Parts may be in any order (width=has unit or width keyword, style=keyword,
+// color=otherwise). Function colors like rgb(255, 0, 0) are kept as single
+// tokens via paren-aware splitting.
func parseBorderTriple(val string) (string, string, string) {
- parts := strings.Fields(val)
+ parts := splitTopLevelWhitespace(val)
borderStyles := map[string]bool{
cssValueNone: true, "hidden": true, "dotted": true, "dashed": true,
"solid": true, "double": true, "groove": true, "ridge": true,
"inset": true, "outset": true,
}
+ borderWidthKeywords := map[string]bool{"thin": true, cssValueMedium: true, "thick": true}
width, style, colorVal := "", "", ""
for _, p := range parts {
+ lower := strings.ToLower(p)
switch {
- case borderStyles[p]:
- style = p
- case isLengthValue(p):
+ case borderStyles[lower]:
+ style = lower
+ case isLengthValue(p) || borderWidthKeywords[lower]:
width = p
default:
colorVal = p
}
}
if width == "" {
- width = "medium"
+ width = cssValueMedium
}
if style == "" {
style = cssValueNone
@@ -306,6 +310,8 @@ func expandBorderRadius(val string) map[string]string {
}
// expandBox expands a box shorthand (padding/margin) into 4 longhands.
+// Invalid declarations (0 or 5+ values) are dropped entirely, matching the
+// CSS rule that malformed declarations are ignored rather than zeroed.
func expandBox(prefix, val string) map[string]string {
parts := strings.Fields(val)
var top, right, bottom, left string
@@ -319,7 +325,7 @@ func expandBox(prefix, val string) map[string]string {
case 4:
top, right, bottom, left = parts[0], parts[1], parts[2], parts[3]
default:
- top, right, bottom, left = "0", "0", "0", "0"
+ return map[string]string{}
}
return map[string]string{
prefix + "-top": top,
@@ -329,19 +335,40 @@ func expandBox(prefix, val string) map[string]string {
}
}
-// expandFont handles the simplified "font: " shorthand.
+// expandFont handles the "font: [style] [weight] [/]
+// " shorthand.
func expandFont(val string) map[string]string {
parts := strings.Fields(val)
out := map[string]string{"font": val}
+ fontStyles := map[string]bool{"italic": true, "oblique": true}
+ fontWeights := map[string]bool{
+ "bold": true, "bolder": true, "lighter": true,
+ "100": true, "200": true, "300": true, "400": true, "500": true,
+ "600": true, "700": true, "800": true, "900": true,
+ }
for i, p := range parts {
- if isLengthValue(p) {
- out["font-size"] = p
- if i+1 < len(parts) {
- out["font-family"] = strings.Join(parts[i+1:], " ")
+ sizePart, lineHeight, hasLineHeight := strings.Cut(p, "/")
+ if !isLengthValue(sizePart) {
+ continue
+ }
+ out["font-size"] = sizePart
+ if hasLineHeight && lineHeight != "" {
+ out["line-height"] = lineHeight
+ }
+ if i+1 < len(parts) {
+ out["font-family"] = strings.Join(parts[i+1:], " ")
+ }
+ for _, prefix := range parts[:i] {
+ lower := strings.ToLower(prefix)
+ switch {
+ case fontStyles[lower]:
+ out["font-style"] = lower
+ case fontWeights[lower]:
+ out["font-weight"] = lower
}
- delete(out, "font")
- return out
}
+ delete(out, "font")
+ return out
}
return out
}
diff --git a/pkg/html/document.go b/pkg/html/document.go
index 42ee5f17..9c2ec3fc 100644
--- a/pkg/html/document.go
+++ b/pkg/html/document.go
@@ -36,7 +36,7 @@ func DocumentFromString(ctx context.Context, htmlStr string, opts ...Option) (*D
}
doc, err := dom.Parse(htmlStr)
if err != nil {
- return nil, err
+ return nil, &translate.ParseError{Err: err}
}
err = conversionCanceled(ctx)
if err != nil {
@@ -90,5 +90,20 @@ func (c *config) translateOptions() []translate.Option {
if c.outlineFromHeadings {
tOpts = append(tOpts, translate.WithOutlineFromHeadings())
}
+ if c.strictAssets {
+ tOpts = append(tOpts, translate.WithStrictAssets())
+ }
+ if c.remoteAssets {
+ tOpts = append(tOpts, translate.WithRemoteAssets())
+ }
+ if c.urlPolicy != nil {
+ tOpts = append(tOpts, translate.WithURLPolicy(c.urlPolicy))
+ }
+ if c.httpClient != nil {
+ tOpts = append(tOpts, translate.WithHTTPClient(c.httpClient))
+ }
+ if c.fallbackFontPath != "" {
+ tOpts = append(tOpts, translate.WithFallbackFontPath(c.fallbackFontPath))
+ }
return tOpts
}
diff --git a/pkg/html/dom/dom.go b/pkg/html/dom/dom.go
index 47682ba9..06bd6e24 100644
--- a/pkg/html/dom/dom.go
+++ b/pkg/html/dom/dom.go
@@ -252,29 +252,40 @@ func isPreformatted(n *html.Node) bool {
return false
}
+// collapseWhitespace folds runs of collapsible (ASCII) whitespace into a
+// single space. Non-breaking spaces (U+00A0) are NOT collapsible per the CSS
+// white-space rules and pass through unchanged.
func collapseWhitespace(s string) string {
if s == "" {
return ""
}
- leading := isASCIISpace(s[0])
- trailing := isASCIISpace(s[len(s)-1])
- fields := strings.Fields(s)
- if len(fields) == 0 {
- if leading || trailing {
- return " "
+ var b strings.Builder
+ b.Grow(len(s))
+ inSpace := false
+ for _, r := range s {
+ if isCollapsibleSpace(r) {
+ inSpace = true
+ continue
}
- return ""
- }
- result := strings.Join(fields, " ")
- if leading {
- result = " " + result
+ if inSpace {
+ b.WriteByte(' ')
+ inSpace = false
+ }
+ b.WriteRune(r)
}
- if trailing {
- result += " "
+ if inSpace {
+ b.WriteByte(' ')
}
- return result
+ return b.String()
}
-func isASCIISpace(b byte) bool {
- return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f' || b == '\v'
+// isCollapsibleSpace reports whether r is ASCII whitespace that collapses
+// under the CSS white-space rules. U+00A0 (nbsp) is intentionally excluded.
+func isCollapsibleSpace(r rune) bool {
+ switch r {
+ case ' ', '\t', '\n', '\r', '\f', '\v':
+ return true
+ default:
+ return false
+ }
}
diff --git a/pkg/html/errors.go b/pkg/html/errors.go
index f4f98fd9..98d9ed8e 100644
--- a/pkg/html/errors.go
+++ b/pkg/html/errors.go
@@ -1,6 +1,34 @@
package html
-import "github.com/avdoseferovic/paper/internal/htmllimits"
+import (
+ "github.com/avdoseferovic/paper/internal/htmllimits"
+ "github.com/avdoseferovic/paper/pkg/html/translate"
+)
+
+type (
+ // ParseError indicates the input HTML could not be parsed into a document.
+ ParseError = translate.ParseError
+
+ // AssetError indicates a document-referenced asset failed to load while
+ // converting HTML with WithStrictAssets enabled.
+ AssetError = translate.AssetError
+
+ // LimitError indicates HTML conversion stopped because a configured
+ // resource ceiling was crossed. Its Kind field reports which one.
+ LimitError = translate.LimitError
+
+ // LimitKind identifies which HTML conversion resource ceiling was exceeded.
+ LimitKind = translate.LimitKind
+)
+
+const (
+ // LimitElements reports that the configured element/node budget was exceeded.
+ LimitElements = translate.LimitElements
+ // LimitDepth reports that the configured DOM nesting depth was exceeded.
+ LimitDepth = translate.LimitDepth
+ // LimitStyleRules reports that the configured stylesheet rule budget was exceeded.
+ LimitStyleRules = translate.LimitStyleRules
+)
var (
// ErrImageTooLarge is returned when an HTML image exceeds configured byte
diff --git a/pkg/html/errors_test.go b/pkg/html/errors_test.go
new file mode 100644
index 00000000..7a492f40
--- /dev/null
+++ b/pkg/html/errors_test.go
@@ -0,0 +1,22 @@
+package html_test
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/pkg/html"
+)
+
+func TestParseErrorWrapsCause(t *testing.T) {
+ t.Parallel()
+
+ cause := errors.New("bad markup")
+ err := &html.ParseError{Err: cause}
+
+ assert.Contains(t, err.Error(), "bad markup")
+ assert.ErrorIs(t, err, cause)
+
+ var empty *html.ParseError
+ assert.NotEmpty(t, empty.Error(), "nil receiver must not panic")
+}
diff --git a/pkg/html/html.go b/pkg/html/html.go
index 78d045a4..fa0884f0 100644
--- a/pkg/html/html.go
+++ b/pkg/html/html.go
@@ -8,6 +8,7 @@ import (
"context"
"fmt"
"io"
+ "net/http"
"github.com/avdoseferovic/paper/pkg/core"
"github.com/avdoseferovic/paper/pkg/html/dom"
@@ -26,6 +27,57 @@ type config struct {
limits Limits
limitsSet bool
outlineFromHeadings bool
+ strictAssets bool
+ remoteAssets bool
+ urlPolicy URLPolicy
+ httpClient *http.Client
+ fallbackFontPath string
+}
+
+// URLPolicy vets a URL before any remote asset fetch. Returning a non-nil
+// error blocks the fetch.
+type URLPolicy = translate.URLPolicy
+
+// WithStrictAssets makes asset load failures (stylesheet links, @font-face
+// sources, images, background images) surface as an error from FromString /
+// FromReader. The partially-converted rows are still returned alongside the
+// joined *AssetError values. Without this option failures warn-and-continue.
+func WithStrictAssets() Option {
+ return func(c *config) {
+ c.strictAssets = true
+ }
+}
+
+// WithRemoteAssets enables http(s) fetching for document-referenced assets.
+// Off by default so untrusted HTML cannot trigger network access.
+func WithRemoteAssets() Option {
+ return func(c *config) {
+ c.remoteAssets = true
+ }
+}
+
+// WithURLPolicy registers a callback that can veto individual URLs before any
+// remote fetch. Only consulted when WithRemoteAssets is enabled.
+func WithURLPolicy(policy URLPolicy) Option {
+ return func(c *config) {
+ c.urlPolicy = policy
+ }
+}
+
+// WithHTTPClient overrides http.DefaultClient for remote asset fetches.
+func WithHTTPClient(client *http.Client) Option {
+ return func(c *config) {
+ c.httpClient = client
+ }
+}
+
+// WithFallbackFontPath registers a font (local path, data: URI, or http(s)
+// URL with WithRemoteAssets) loaded on demand when the document contains text
+// outside the WinAnsi (cp1252) repertoire.
+func WithFallbackFontPath(path string) Option {
+ return func(c *config) {
+ c.fallbackFontPath = path
+ }
}
// WithOutlineFromHeadings adds h1-h6 headings to the PDF document outline:
@@ -97,7 +149,7 @@ func FromString(ctx context.Context, htmlStr string, opts ...Option) ([]core.Row
}
doc, err := dom.Parse(htmlStr)
if err != nil {
- return nil, err
+ return nil, &translate.ParseError{Err: err}
}
err = conversionCanceled(ctx)
if err != nil {
diff --git a/pkg/html/limit_error_test.go b/pkg/html/limit_error_test.go
new file mode 100644
index 00000000..b84756ce
--- /dev/null
+++ b/pkg/html/limit_error_test.go
@@ -0,0 +1,82 @@
+package html_test
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/html"
+)
+
+func TestLimitErrorFromMaxElements(t *testing.T) {
+ t.Parallel()
+
+ _, err := html.FromString(context.Background(), repeatedSpans(20), html.WithMaxElements(10))
+
+ var le *html.LimitError
+ require.True(t, errors.As(err, &le), "expected *html.LimitError")
+ assert.Equal(t, html.LimitElements, le.Kind)
+ assert.Equal(t, 10, le.Limit)
+ require.ErrorIs(t, err, html.ErrDOMTooLarge)
+}
+
+func TestLimitErrorFromMaxDepth(t *testing.T) {
+ t.Parallel()
+
+ _, err := html.FromString(context.Background(), nestedDivs(20), html.WithMaxDepth(5))
+
+ var le *html.LimitError
+ require.True(t, errors.As(err, &le), "expected *html.LimitError")
+ assert.Equal(t, html.LimitDepth, le.Kind)
+ assert.Equal(t, 5, le.Limit)
+ require.ErrorIs(t, err, html.ErrDOMTooDeep)
+}
+
+func TestLimitErrorFromStyleRuleLimit(t *testing.T) {
+ t.Parallel()
+
+ _, err := html.FromString(context.Background(), manyStyleRules(20), html.WithLimits(html.Limits{MaxStyleRules: 5}))
+
+ var le *html.LimitError
+ require.True(t, errors.As(err, &le), "expected *html.LimitError")
+ assert.Equal(t, html.LimitStyleRules, le.Kind)
+ assert.Equal(t, 5, le.Limit)
+ require.ErrorIs(t, err, html.ErrStyleRulesTooLarge)
+}
+
+func TestLimitErrorKindString(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "elements", html.LimitElements.String())
+ assert.Equal(t, "depth", html.LimitDepth.String())
+ assert.Equal(t, "style-rules", html.LimitStyleRules.String())
+}
+
+func repeatedSpans(n int) string {
+ var b strings.Builder
+ b.WriteString("")
+ for range n {
+ b.WriteString("x")
+ }
+ b.WriteString("")
+ return b.String()
+}
+
+func nestedDivs(depth int) string {
+ return "" + strings.Repeat("", depth) + "x" + strings.Repeat("
", depth) + ""
+}
+
+func manyStyleRules(n int) string {
+ var b strings.Builder
+ b.WriteString("ok
")
+ return b.String()
+}
diff --git a/pkg/html/limits.go b/pkg/html/limits.go
index 04b82fbe..e30c08cb 100644
--- a/pkg/html/limits.go
+++ b/pkg/html/limits.go
@@ -27,3 +27,27 @@ func WithUnsafeNoLimits() Option {
c.limitsSet = true
}
}
+
+// WithMaxElements caps the number of DOM nodes translated. Exceeding the cap
+// returns a *LimitError with Kind LimitElements. Other limits keep their safe
+// default values.
+func WithMaxElements(n int) Option {
+ return func(c *config) {
+ if n > 0 {
+ c.limits.MaxDOMNodes = n
+ c.limitsSet = true
+ }
+ }
+}
+
+// WithMaxDepth caps the DOM nesting depth translated. Exceeding the cap
+// returns a *LimitError with Kind LimitDepth. Other limits keep their safe
+// default values.
+func WithMaxDepth(n int) Option {
+ return func(c *config) {
+ if n > 0 {
+ c.limits.MaxDOMDepth = n
+ c.limitsSet = true
+ }
+ }
+}
diff --git a/pkg/html/options_remote_test.go b/pkg/html/options_remote_test.go
new file mode 100644
index 00000000..b7c0b018
--- /dev/null
+++ b/pkg/html/options_remote_test.go
@@ -0,0 +1,95 @@
+package html_test
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/html"
+)
+
+const remoteCSS = "p { color: rgb(200, 0, 0); }"
+
+func newAssetServer(t *testing.T) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("/style.css", func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(remoteCSS))
+ })
+ server := httptest.NewServer(mux)
+ t.Cleanup(server.Close)
+ return server
+}
+
+func TestWithRemoteAssetsLoadsRemoteStylesheet(t *testing.T) {
+ t.Parallel()
+
+ server := newAssetServer(t)
+ rows, err := html.FromString(context.Background(),
+ `styled
`,
+ html.WithRemoteAssets(),
+ )
+
+ require.NoError(t, err)
+ assert.NotEmpty(t, rows)
+}
+
+func TestWithURLPolicyBlocksFetches(t *testing.T) {
+ t.Parallel()
+
+ server := newAssetServer(t)
+ denied := errors.New("denied by policy")
+ rows, err := html.FromString(context.Background(),
+ `ok
`,
+ html.WithRemoteAssets(),
+ html.WithURLPolicy(func(string) error { return denied }),
+ )
+
+ // Policy denials warn-and-continue; the document still renders.
+ require.NoError(t, err)
+ assert.NotEmpty(t, rows)
+}
+
+func TestWithHTTPClientIsUsedForFetches(t *testing.T) {
+ t.Parallel()
+
+ server := newAssetServer(t)
+ used := false
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ used = true
+ return http.DefaultTransport.RoundTrip(req)
+ })}
+ _, err := html.FromString(context.Background(),
+ `ok
`,
+ html.WithRemoteAssets(),
+ html.WithHTTPClient(client),
+ )
+
+ require.NoError(t, err)
+ assert.True(t, used, "custom HTTP client must serve remote asset fetches")
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
+
+func TestWithFallbackFontPathRendersNonWinAnsiText(t *testing.T) {
+ t.Parallel()
+
+ const fontPath = "../../docs/assets/fonts/arial-unicode-ms.ttf"
+ _, statErr := os.Stat(fontPath)
+ require.NoError(t, statErr)
+
+ rows, err := html.FromString(context.Background(),
+ `Zażółć gęślą jaźń
`,
+ html.WithFallbackFontPath(fontPath),
+ )
+
+ require.NoError(t, err)
+ assert.NotEmpty(t, rows)
+}
diff --git a/pkg/html/strict_assets_test.go b/pkg/html/strict_assets_test.go
new file mode 100644
index 00000000..836c440d
--- /dev/null
+++ b/pkg/html/strict_assets_test.go
@@ -0,0 +1,131 @@
+package html_test
+
+import (
+ "context"
+ "errors"
+ "io/fs"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/html"
+)
+
+func TestWithStrictAssets_DefaultFalsePreservesWarnAndContinue(t *testing.T) {
+ t.Parallel()
+
+ rows, err := html.FromString(context.Background(),
+ `
ok
`,
+ html.WithImageBaseDir(t.TempDir()),
+ )
+
+ require.NoError(t, err)
+ assert.NotEmpty(t, rows)
+}
+
+func TestWithStrictAssets_BadStylesheetReturnsAssetError(t *testing.T) {
+ t.Parallel()
+
+ rows, err := html.FromString(context.Background(),
+ `ok
`,
+ html.WithStylesheetBaseDir(t.TempDir()),
+ html.WithStrictAssets(),
+ )
+
+ require.Error(t, err)
+ assert.NotEmpty(t, rows)
+ assertAssetError(t, err, "stylesheet", "missing.css")
+ require.ErrorIs(t, err, fs.ErrNotExist)
+}
+
+func TestWithStrictAssets_BadFontFaceReturnsAssetError(t *testing.T) {
+ t.Parallel()
+
+ rows, err := html.FromString(context.Background(),
+ `ok
`,
+ html.WithStylesheetBaseDir(t.TempDir()),
+ html.WithStrictAssets(),
+ )
+
+ require.Error(t, err)
+ assert.NotEmpty(t, rows)
+ assertAssetError(t, err, "@font-face", "missing.ttf")
+ require.ErrorIs(t, err, fs.ErrNotExist)
+}
+
+func TestWithStrictAssets_BadImageReturnsAssetError(t *testing.T) {
+ t.Parallel()
+
+ rows, err := html.FromString(context.Background(),
+ `
ok
`,
+ html.WithImageBaseDir(t.TempDir()),
+ html.WithStrictAssets(),
+ )
+
+ require.Error(t, err)
+ assert.NotEmpty(t, rows)
+ assertAssetError(t, err, "image", "missing.png")
+ require.ErrorIs(t, err, fs.ErrNotExist)
+}
+
+func TestWithStrictAssets_BadBackgroundImageReturnsAssetError(t *testing.T) {
+ t.Parallel()
+
+ rows, err := html.FromString(context.Background(),
+ `ok
`,
+ html.WithImageBaseDir(t.TempDir()),
+ html.WithStrictAssets(),
+ )
+
+ require.Error(t, err)
+ assert.NotEmpty(t, rows)
+ assertAssetError(t, err, "background-image", "missing-bg.png")
+ require.ErrorIs(t, err, fs.ErrNotExist)
+}
+
+func TestWithStrictAssets_CollectsMultipleAssetErrors(t *testing.T) {
+ t.Parallel()
+
+ _, err := html.FromString(context.Background(),
+ `
`,
+ html.WithStylesheetBaseDir(t.TempDir()),
+ html.WithImageBaseDir(t.TempDir()),
+ html.WithStrictAssets(),
+ )
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "missing.css")
+ assert.Contains(t, err.Error(), "missing.png")
+ assertAssetError(t, err, "stylesheet", "missing.css")
+ assertAssetError(t, err, "image", "missing.png")
+}
+
+func assertAssetError(t *testing.T, err error, category, ref string) {
+ t.Helper()
+
+ ae, ok := findAssetError(err, category, ref)
+ require.True(t, ok, "expected *html.AssetError")
+ assert.Equal(t, category, ae.Category)
+ assert.Equal(t, ref, ae.Ref)
+}
+
+func findAssetError(err error, category, ref string) (*html.AssetError, bool) {
+ if err == nil {
+ return nil, false
+ }
+ var ae *html.AssetError
+ if errors.As(err, &ae) && ae.Category == category && ae.Ref == ref {
+ return ae, true
+ }
+ if joined, ok := err.(interface{ Unwrap() []error }); ok {
+ for _, child := range joined.Unwrap() {
+ if ae, ok := findAssetError(child, category, ref); ok {
+ return ae, true
+ }
+ }
+ }
+ if wrapped, ok := err.(interface{ Unwrap() error }); ok {
+ return findAssetError(wrapped.Unwrap(), category, ref)
+ }
+ return nil, false
+}
diff --git a/pkg/html/translate/block_margins.go b/pkg/html/translate/block_margins.go
new file mode 100644
index 00000000..37f720ff
--- /dev/null
+++ b/pkg/html/translate/block_margins.go
@@ -0,0 +1,181 @@
+// Block margin, page-break, and page-control helpers for the translator
+// walker (split from translate.go).
+package translate
+
+import (
+ "strings"
+
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/html/css"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+)
+
+func applyBlockMargins(n *dom.Node, style *css.ComputedStyle, rows []core.Row) []core.Row {
+ if style == nil || n.Tag() == "" || len(rows) == 0 || selfHandlesMargin(n.Tag()) {
+ return rows
+ }
+ // Honor block-level margins outside the element's content box. Horizontal
+ // margins narrow/offset each produced content row; vertical margins remain
+ // flow spacers so they can collapse with adjacent block margins.
+ if style.MarginLeft != 0 || style.MarginRight != 0 {
+ rows = wrapRowsWithHorizontalMargins(rows, style.MarginLeft, style.MarginRight)
+ }
+ if style.MarginTop > 0 {
+ rows = append([]core.Row{newMarginSpacer(style.MarginTop)}, rows...)
+ }
+ if style.MarginBottom > 0 {
+ rows = append(rows, newMarginSpacer(style.MarginBottom))
+ }
+ // CSS margins collapse: adjacent/last-child margins reduce to the larger,
+ // they do not sum. Merge touching margin spacers so stacked block margins
+ // don't accumulate. (Containers with padding/border are opaque rows, so
+ // margins never collapse through them — matching CSS.)
+ return collapseMarginSpacers(rows)
+}
+
+func withPageBreakRows(style *css.ComputedStyle, rows []core.Row) []core.Row {
+ if style != nil {
+ if style.PageBreakBefore == "always" {
+ rows = append([]core.Row{NewPageBreakRow()}, rows...)
+ }
+ if style.PageBreakAfter == "always" {
+ rows = append(rows, NewPageBreakRow())
+ }
+ }
+ return rows
+}
+
+func wrapRowsWithHorizontalMargins(rows []core.Row, marginLeft, marginRight float64) []core.Row {
+ out := make([]core.Row, 0, len(rows))
+ for _, r := range rows {
+ if _, ok := r.(marginSpacerRow); ok {
+ out = append(out, r)
+ continue
+ }
+ out = append(out, &horizontalMarginRow{
+ child: r,
+ marginLeft: marginLeft,
+ marginRight: marginRight,
+ })
+ }
+ return out
+}
+
+func pageControlFromStyle(style, parent *css.ComputedStyle) (core.PageControl, bool) {
+ if style == nil || len(style.Vars) == 0 {
+ return core.PageControl{}, false
+ }
+ pageNumberCount := cssVarDeclaredEquals(style, parent, "--paper-page-number", "count")
+ control := core.PageControl{
+ Blank: cssVarDeclaredEquals(style, parent, "--paper-page", "blank"),
+ SuppressFooter: cssVarDeclaredEquals(style, parent, "--paper-page-footer", "none"),
+ SuppressPageNumber: cssVarDeclaredEquals(style, parent, "--paper-page-number", "none") || pageNumberCount,
+ CountPageNumber: pageNumberCount,
+ DecorFirstPageOnly: cssVarDeclaredEquals(style, parent, "--paper-page-decor-scope", "first-page"),
+ TopMarginFirstPageOnly: cssVarDeclaredEquals(style, parent, "--paper-page-top-margin-scope", "first-page"),
+ RenderOffsetFirstPageOnly: cssVarDeclaredEquals(style, parent, "--paper-page-render-offset-scope", "first-page"),
+ }
+ if value, ok := cssVarDeclaredValue(style, parent, "--paper-page-top-margin"); ok {
+ top := css.ParseLength(value, style.FontSize)
+ control.TopMargin = &top
+ }
+ if value, ok := cssVarDeclaredValue(style, parent, "--paper-page-top-margin-continuation"); ok {
+ top := css.ParseLength(value, style.FontSize)
+ control.ContinuationTopMargin = &top
+ }
+ if value, ok := cssVarDeclaredValue(style, parent, "--paper-page-render-offset-x"); ok {
+ x := css.ParseLength(value, style.FontSize)
+ control.RenderOffsetX = &x
+ }
+ if value, ok := cssVarDeclaredValue(style, parent, "--paper-page-render-offset-y"); ok {
+ y := css.ParseLength(value, style.FontSize)
+ control.RenderOffsetY = &y
+ }
+ if value, ok := cssVarDeclaredValue(style, parent, "--paper-page-render-offset-x-continuation"); ok {
+ x := css.ParseLength(value, style.FontSize)
+ control.ContinuationRenderOffsetX = &x
+ }
+ if value, ok := cssVarDeclaredValue(style, parent, "--paper-page-render-offset-y-continuation"); ok {
+ y := css.ParseLength(value, style.FontSize)
+ control.ContinuationRenderOffsetY = &y
+ }
+ return control, pageControlHasEffect(control)
+}
+
+func pageControlHasEffect(control core.PageControl) bool {
+ return control.Blank ||
+ control.SuppressFooter ||
+ control.SuppressPageNumber ||
+ control.CountPageNumber ||
+ control.DecorFirstPageOnly ||
+ control.TopMargin != nil ||
+ control.TopMarginFirstPageOnly ||
+ control.ContinuationTopMargin != nil ||
+ control.RenderOffsetX != nil ||
+ control.RenderOffsetY != nil ||
+ control.RenderOffsetFirstPageOnly ||
+ control.ContinuationRenderOffsetX != nil ||
+ control.ContinuationRenderOffsetY != nil
+}
+
+func cssVarDeclaredEquals(style, parent *css.ComputedStyle, name, want string) bool {
+ value, ok := cssVarDeclaredValue(style, parent, name)
+ return ok && strings.EqualFold(strings.TrimSpace(value), want)
+}
+
+func cssVarDeclaredValue(style, parent *css.ComputedStyle, name string) (string, bool) {
+ value, ok := style.Vars[name]
+ if !ok {
+ return "", false
+ }
+ if parent == nil || len(parent.Vars) == 0 {
+ return value, true
+ }
+ parentValue, inherited := parent.Vars[name]
+ return value, !inherited || parentValue != value
+}
+
+// marginSpacerRow is an empty flow spacer emitted for a block's vertical margin.
+// It is a distinct type so collapseMarginSpacers can recognise and merge
+// adjacent ones (CSS margin collapsing).
+type marginSpacerRow struct {
+ core.Row
+ height float64
+}
+
+func newMarginSpacer(h float64) marginSpacerRow {
+ return marginSpacerRow{Row: spacerRow(h), height: h}
+}
+
+// collapseMarginSpacers merges each run of adjacent margin spacers into a single
+// spacer of the largest height, approximating CSS adjacent and parent/last-child
+// margin collapsing so stacked block margins reduce to the larger rather than
+// summing. Non-margin rows (content, page breaks, flex gaps) are untouched.
+func collapseMarginSpacers(rows []core.Row) []core.Row {
+ out := make([]core.Row, 0, len(rows))
+ for _, r := range rows {
+ if ms, ok := r.(marginSpacerRow); ok && len(out) > 0 {
+ if prev, prevOK := out[len(out)-1].(marginSpacerRow); prevOK {
+ if ms.height > prev.height {
+ out[len(out)-1] = ms
+ }
+ continue
+ }
+ }
+ out = append(out, r)
+ }
+ return out
+}
+
+// selfHandlesMargin reports whether a tag's dedicated translation already
+// applies its own CSS margins (via marginBox), so blockRowsWithParent must not
+// add margin spacers on top (which would double them). Lists wrap their content
+// in a marginBox that also carries horizontal indent.
+func selfHandlesMargin(tag string) bool {
+ switch tag {
+ case "ul", "ol", "dl":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/pkg/html/translate/block_tags_helpers_test.go b/pkg/html/translate/block_tags_helpers_test.go
new file mode 100644
index 00000000..89bb0610
--- /dev/null
+++ b/pkg/html/translate/block_tags_helpers_test.go
@@ -0,0 +1,52 @@
+package translate
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/consts/fontstyle"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+// parseInternalDoc parses HTML for internal-package tests (the external test
+// package has its own parseDoc helper).
+func parseInternalDoc(t *testing.T, src string) *dom.Document {
+ t.Helper()
+ doc, err := dom.Parse(src)
+ require.NoError(t, err)
+ return doc
+}
+
+// setBlockTagTestConfig applies a default render config to all rows.
+func setBlockTagTestConfig(rows []core.Row) {
+ cfg := &entity.Config{
+ MaxGridSize: 12,
+ DefaultFont: &props.Font{
+ Family: "Helvetica",
+ Style: fontstyle.Normal,
+ Size: 10,
+ },
+ }
+ for _, r := range rows {
+ r.SetConfig(cfg)
+ }
+}
+
+type blockTagTextCall struct {
+ text string
+ prop *props.Text
+}
+
+// blockTagRecordingProvider records AddText calls so tests can assert on the
+// rendered text and its resolved props.
+type blockTagRecordingProvider struct {
+ cursorProvider
+ texts []blockTagTextCall
+}
+
+func (p *blockTagRecordingProvider) AddText(text string, _ *entity.Cell, prop *props.Text) {
+ p.texts = append(p.texts, blockTagTextCall{text: text, prop: prop})
+}
diff --git a/pkg/html/translate/bugfixes_internal_test.go b/pkg/html/translate/bugfixes_internal_test.go
new file mode 100644
index 00000000..5d0e8ad6
--- /dev/null
+++ b/pkg/html/translate/bugfixes_internal_test.go
@@ -0,0 +1,121 @@
+package translate
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/components/htmllist"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+)
+
+func translateHTML(t *testing.T, htmlStr string) []string {
+ t.Helper()
+ doc, err := dom.Parse(htmlStr)
+ require.NoError(t, err)
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ return richTextValues(rows)
+}
+
+func TestTableFootRendersAfterBody(t *testing.T) {
+ t.Parallel()
+
+ doc, err := dom.Parse(``)
+ require.NoError(t, err)
+ tr := &translator{}
+ var tableNode *dom.Node
+ doc.Walk(func(n *dom.Node) bool {
+ if n.Tag() == "table" {
+ tableNode = n
+ return false
+ }
+ return true
+ })
+ require.NotNil(t, tableNode)
+ matrix := tr.buildTableMatrix(tableNode, nil)
+ require.Equal(t, 3, len(matrix))
+
+ var order []string
+ for _, r := range matrix {
+ for _, cell := range r {
+ if rt, ok := cell.Content.(interface{ PlainText() string }); ok {
+ order = append(order, rt.PlainText())
+ }
+ }
+ }
+ if len(order) == 3 {
+ assert.Equal(t, []string{"HEAD", "BODY", "FOOT"}, order)
+ }
+}
+
+func TestBodyLevelInlineContentSharesOneRow(t *testing.T) {
+ t.Parallel()
+
+ values := translateHTML(t, `Total: $5 due`)
+ require.Equal(t, 1, len(values))
+ assert.Equal(t, "Total: $5 due", strings.TrimSpace(values[0]))
+}
+
+func TestOrderedListStartZero(t *testing.T) {
+ t.Parallel()
+
+ doc, err := dom.Parse(`- zero
- one
`)
+ require.NoError(t, err)
+ tr := &translator{}
+ var list *htmllist.HTMLList
+ doc.Walk(func(n *dom.Node) bool {
+ if n.Tag() == "ol" {
+ list = tr.buildList(n)
+ return false
+ }
+ return true
+ })
+ require.NotNil(t, list)
+ marker0, ok := list.MarkerLabel(0)
+ require.True(t, ok)
+ assert.Equal(t, "0.", marker0)
+ marker1, ok := list.MarkerLabel(1)
+ require.True(t, ok)
+ assert.Equal(t, "1.", marker1)
+}
+
+func TestInlineShorthandLonghandOrderIsDeterministic(t *testing.T) {
+ t.Parallel()
+
+ // Run repeatedly: with map-order expansion this failed ~15% of the time.
+ for range 100 {
+ decls := parseInlineStyle("margin: 0; margin-top: 10px")
+ require.Equal(t, "10px", decls["margin-top"])
+ require.Equal(t, "0", decls["margin-bottom"])
+ }
+ for range 100 {
+ decls := parseInlineStyle("margin-top: 10px; margin: 0")
+ require.Equal(t, "0", decls["margin-top"])
+ }
+}
+
+func TestCSSContentStringHexEscapes(t *testing.T) {
+ t.Parallel()
+
+ // Per CSS Syntax §4.3.7 the single whitespace after the hex digits
+ // terminates the escape and is consumed; a second space is literal.
+ text, rest, ok := readCSSContentString(`"\2022 item"`)
+ require.True(t, ok)
+ assert.Equal(t, "•item", text)
+ assert.Equal(t, "", rest)
+
+ text, _, ok = readCSSContentString(`"\2022 item"`)
+ require.True(t, ok)
+ assert.Equal(t, "• item", text)
+
+ text, _, ok = readCSSContentString(`"a\a b"`)
+ require.True(t, ok)
+ assert.Equal(t, "a\nb", text)
+
+ text, _, ok = readCSSContentString(`"say \"hi\""`)
+ require.True(t, ok)
+ assert.Equal(t, `say "hi"`, text)
+}
diff --git a/pkg/html/translate/constants.go b/pkg/html/translate/constants.go
index 47136c0e..8f93a99e 100644
--- a/pkg/html/translate/constants.go
+++ b/pkg/html/translate/constants.go
@@ -1,19 +1,25 @@
package translate
const (
+ cssValueAll = "all"
cssValueAuto = "auto"
cssValueHidden = "hidden"
cssValueNormal = "normal"
cssValueNone = "none"
+ cssValueSolid = "solid"
+ cssValueRight = "right"
displayFlex = "flex"
+ displayGrid = "grid"
displayNone = cssValueNone
- flexAlignCenter = "center"
- flexAlignEnd = "flex-end"
- flexAlignStart = "flex-start"
- flexWrap = "wrap"
- flexWrapReverse = "wrap-reverse"
+ flexAlignCenter = "center"
+ flexAlignEnd = "flex-end"
+ flexAlignStart = "flex-start"
+ flexLogicalEnd = "end"
+ flexLogicalStart = "start"
+ flexWrap = "wrap"
+ flexWrapReverse = "wrap-reverse"
imageExtJPG = "jpg"
imageExtPNG = "png"
@@ -21,10 +27,22 @@ const (
listStyleDecimal = "decimal"
- tagImg = "img"
- tagPicture = "picture"
- tagTable = "table"
- tagSVG = "svg"
+ positionAbsolute = "absolute"
+ positionFixed = "fixed"
+ positionRelative = "relative"
+
+ tagButton = "button"
+ tagFieldset = "fieldset"
+ tagImg = "img"
+ tagInput = "input"
+ tagLegend = "legend"
+ tagOptgroup = "optgroup"
+ tagOption = "option"
+ tagPicture = "picture"
+ tagSelect = "select"
+ tagSVG = "svg"
+ tagTable = "table"
+ tagTextArea = "textarea"
verticalAlignBaseline = "baseline"
verticalAlignSub = "sub"
diff --git a/pkg/html/translate/container.go b/pkg/html/translate/container.go
index 763d4482..e3afa185 100644
--- a/pkg/html/translate/container.go
+++ b/pkg/html/translate/container.go
@@ -1,6 +1,7 @@
package translate
import (
+ "context"
"maps"
"github.com/avdoseferovic/paper/pkg/components/col"
@@ -360,6 +361,13 @@ func (tr *translator) buildContainerRow(style *css.ComputedStyle, childRows []co
return newSplittableContainerRow(container)
}
+// buildContainerRowContext is buildContainerRow for context-aware call paths.
+// Row assembly is synchronous, so ctx is not consulted beyond the caller's own
+// cancellation checks.
+func (tr *translator) buildContainerRowContext(_ context.Context, style *css.ComputedStyle, childRows []core.Row) core.Row {
+ return tr.buildContainerRow(style, childRows)
+}
+
// splittableContainerRow wraps a blockContainer in a real row.Row (so it
// renders identically to the pre-Plan-B output) and also exposes SplitAt so
// paper.addRow() can split it across pages.
diff --git a/pkg/html/translate/cssparse.go b/pkg/html/translate/cssparse.go
index 7830684d..9bcb7fbc 100644
--- a/pkg/html/translate/cssparse.go
+++ b/pkg/html/translate/cssparse.go
@@ -26,10 +26,13 @@ const (
)
// cssDeclaration is a single `property: value` pair. value has any trailing
-// `!important` stripped, matching douceur's Declaration.Value semantics.
+// `!important` stripped (matching douceur's Declaration.Value semantics);
+// important records whether the suffix was present so the cascade can rank
+// the declaration above normal ones.
type cssDeclaration struct {
- property string
- value string
+ property string
+ value string
+ important bool
}
// cssRule mirrors the minimal subset of douceur's css.Rule that the translator
@@ -178,9 +181,11 @@ func (s *cssParseState) declaration(data string, values []tcss.Token) error {
if err != nil {
return err
}
+ value, important := declValue(values)
target.declarations = append(target.declarations, cssDeclaration{
- property: data,
- value: declValue(values),
+ property: data,
+ value: value,
+ important: important,
})
return nil
}
@@ -204,15 +209,21 @@ func concatTokens(tokens []tcss.Token) string {
return strings.TrimSpace(b.String())
}
-// declValue reconstructs a declaration value and strips a trailing !important,
-// matching douceur's Declaration.Value (which excluded it).
-func declValue(tokens []tcss.Token) string {
- v := concatTokens(tokens)
+// declValue reconstructs a declaration value and strips a trailing !important
+// (matching douceur's Declaration.Value, which excluded it), reporting whether
+// the suffix was present.
+func declValue(tokens []tcss.Token) (string, bool) {
+ return stripImportantSuffix(concatTokens(tokens))
+}
+
+// stripImportantSuffix removes a trailing "!important" (case-insensitive) and
+// reports whether it was present.
+func stripImportantSuffix(v string) (string, bool) {
if i := strings.LastIndex(strings.ToLower(v), "!important"); i >= 0 &&
strings.TrimSpace(v[i+len("!important"):]) == "" {
- v = strings.TrimSpace(v[:i])
+ return strings.TrimSpace(v[:i]), true
}
- return v
+ return v, false
}
// splitSelectors reconstructs the selector group and splits it on top-level
diff --git a/pkg/html/translate/errors.go b/pkg/html/translate/errors.go
new file mode 100644
index 00000000..fd825f97
--- /dev/null
+++ b/pkg/html/translate/errors.go
@@ -0,0 +1,133 @@
+package translate
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/avdoseferovic/paper/internal/htmllimits"
+)
+
+// ErrURLPolicyDenied wraps a URLPolicy rejection so callers can distinguish
+// intentional policy blocks from network or filesystem failures.
+var ErrURLPolicyDenied = errors.New("html: URL fetch blocked by URLPolicy")
+
+// ParseError indicates the input HTML could not be parsed into a document.
+type ParseError struct {
+ Err error
+}
+
+func (e *ParseError) Error() string {
+ if e == nil || e.Err == nil {
+ return "html: parse error"
+ }
+ return "html: parsing input: " + e.Err.Error()
+}
+
+func (e *ParseError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.Err
+}
+
+// AssetError indicates a document-referenced asset failed to load while
+// translating HTML with strict asset checking enabled.
+type AssetError struct {
+ Category string
+ Ref string
+ Err error
+}
+
+func NewAssetError(category, ref string, err error) *AssetError {
+ return &AssetError{Category: category, Ref: ref, Err: err}
+}
+
+func (e *AssetError) Error() string {
+ if e == nil {
+ return "html: asset error"
+ }
+ msg := fmt.Sprintf("html: %s asset", e.Category)
+ if e.Ref != "" {
+ msg += " " + fmt.Sprintf("%q", e.Ref)
+ }
+ if e.Err != nil {
+ msg += ": " + e.Err.Error()
+ }
+ return msg
+}
+
+func (e *AssetError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.Err
+}
+
+// LimitKind identifies which HTML translation resource ceiling was exceeded.
+type LimitKind int
+
+const (
+ // LimitElements reports that the configured element/node budget was exceeded.
+ LimitElements LimitKind = iota
+ // LimitDepth reports that the configured DOM nesting depth was exceeded.
+ LimitDepth
+ // LimitStyleRules reports that the configured stylesheet rule budget was exceeded.
+ LimitStyleRules
+)
+
+func (k LimitKind) String() string {
+ switch k {
+ case LimitElements:
+ return "elements"
+ case LimitDepth:
+ return "depth"
+ case LimitStyleRules:
+ return "style-rules"
+ default:
+ return "elements"
+ }
+}
+
+// LimitError indicates HTML translation stopped because a configured resource
+// ceiling was crossed.
+type LimitError struct {
+ Kind LimitKind
+ Limit int
+ Err error
+}
+
+func (e *LimitError) Error() string {
+ if e == nil {
+ return "html: limit exceeded"
+ }
+ switch e.Kind {
+ case LimitElements:
+ return fmt.Sprintf("html: element count exceeded limit of %d", e.Limit)
+ case LimitDepth:
+ return fmt.Sprintf("html: nesting depth exceeded limit of %d", e.Limit)
+ case LimitStyleRules:
+ return fmt.Sprintf("html: stylesheet rule count exceeded limit of %d", e.Limit)
+ default:
+ return fmt.Sprintf("html: element count exceeded limit of %d", e.Limit)
+ }
+}
+
+func (e *LimitError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.Err
+}
+
+func limitErrorFrom(err error, limits htmllimits.Limits) error {
+ switch {
+ case errors.Is(err, htmllimits.ErrDOMTooDeep):
+ return &LimitError{Kind: LimitDepth, Limit: limits.MaxDOMDepth, Err: err}
+ case errors.Is(err, htmllimits.ErrDOMTooLarge):
+ return &LimitError{Kind: LimitElements, Limit: limits.MaxDOMNodes, Err: err}
+ case errors.Is(err, htmllimits.ErrStyleRulesTooLarge):
+ return &LimitError{Kind: LimitStyleRules, Limit: limits.MaxStyleRules, Err: err}
+ default:
+ return err
+ }
+}
diff --git a/pkg/html/translate/fallback_font.go b/pkg/html/translate/fallback_font.go
new file mode 100644
index 00000000..bd5eeef5
--- /dev/null
+++ b/pkg/html/translate/fallback_font.go
@@ -0,0 +1,219 @@
+package translate
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/avdoseferovic/paper/pkg/consts"
+ "github.com/avdoseferovic/paper/pkg/consts/fontstyle"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+ "github.com/avdoseferovic/paper/pkg/props"
+ "golang.org/x/text/encoding/charmap"
+)
+
+const (
+ fallbackFontFamily = "__paper_html_fallback"
+ maxFallbackFontBytes = 50 << 20
+)
+
+var (
+ errFallbackFontPathEmpty = errors.New("html: fallback font path is empty")
+ errRemoteFallbackFontRefuse = errors.New("html: remote fallback font refused; configure WithURLPolicy or WithHTTPClient")
+ errFallbackFontTooLarge = errors.New("html: fallback font exceeds configured limit")
+)
+
+func (tr *translator) loadFallbackFontIfNeeded(ctx context.Context, body *dom.Node, resolver StylesheetResolver) {
+ if tr == nil || strings.TrimSpace(tr.fallbackFontPath) == "" || body == nil {
+ return
+ }
+ if !nodeContainsNonWinAnsiText(body) {
+ return
+ }
+ data, err := tr.loadFallbackFont(ctx, resolver, tr.fallbackFontPath)
+ if err != nil {
+ tr.unsupported("fallback-font.skipped", tr.fallbackFontPath)
+ tr.reportAssetError("FallbackFontPath", tr.fallbackFontPath, err)
+ return
+ }
+ for _, style := range fallbackFontStyles() {
+ tr.loadedFonts = append(tr.loadedFonts, loadedFont{
+ family: fallbackFontFamily,
+ style: style,
+ bytes: data,
+ })
+ }
+ tr.fallbackFontReady = true
+}
+
+func (tr *translator) loadFallbackFont(ctx context.Context, resolver StylesheetResolver, ref string) ([]byte, error) {
+ ref = strings.TrimSpace(ref)
+ if ref == "" {
+ return nil, errFallbackFontPathEmpty
+ }
+ if strings.HasPrefix(ref, "data:") {
+ data, _, err := decodeDataURIWithLimits(ref, tr.limits)
+ if err != nil {
+ return nil, fmt.Errorf("html: loading fallback font %q: %w", ref, err)
+ }
+ return data, nil
+ }
+ if isHTTPURL(ref) {
+ if tr.remoteAssets {
+ return tr.fetchRemoteURL(ctx, ref, maxFallbackFontBytes)
+ }
+ return nil, errRemoteFallbackFontRefuse
+ }
+
+ var resolverErr error
+ if !filepath.IsAbs(ref) && resolver != nil {
+ data, err := safeLoadStylesheetErr(resolver, ref)
+ if err == nil {
+ return data, nil
+ } else {
+ resolverErr = err
+ }
+ }
+ data, err := readProgrammaticFallbackFont(ref)
+ if err != nil {
+ if resolverErr != nil {
+ return nil, fmt.Errorf("%w; OS fallback: %w", resolverErr, err)
+ }
+ return nil, err
+ }
+ return data, nil
+}
+
+func readProgrammaticFallbackFont(ref string) ([]byte, error) {
+ f, err := os.Open(ref)
+ if err != nil {
+ return nil, fmt.Errorf("html: opening fallback font %q: %w", ref, err)
+ }
+ defer f.Close()
+ data, err := io.ReadAll(io.LimitReader(f, maxFallbackFontBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("html: reading fallback font %q: %w", ref, err)
+ }
+ if int64(len(data)) > maxFallbackFontBytes {
+ return nil, fmt.Errorf("%w: %q exceeds %d bytes", errFallbackFontTooLarge, ref, maxFallbackFontBytes)
+ }
+ return data, nil
+}
+
+func fallbackFontStyles() []fontstyle.Type {
+ return []fontstyle.Type{
+ fontstyle.Normal,
+ fontstyle.Bold,
+ fontstyle.Italic,
+ fontstyle.BoldItalic,
+ fontstyle.Semibold,
+ fontstyle.SemiboldItalic,
+ }
+}
+
+func nodeContainsNonWinAnsiText(n *dom.Node) bool {
+ if n == nil {
+ return false
+ }
+ switch n.Tag() {
+ case "script", "style":
+ return false
+ case "":
+ return textContainsNonWinAnsi(n.TextContent())
+ }
+ return slices.ContainsFunc(n.Children(), nodeContainsNonWinAnsiText)
+}
+
+func textContainsNonWinAnsi(text string) bool {
+ for _, r := range text {
+ if !canEncodeWinAnsiRune(r) {
+ return true
+ }
+ }
+ return false
+}
+
+func canEncodeWinAnsiRune(r rune) bool {
+ _, err := charmap.Windows1252.NewEncoder().String(string(r))
+ return err == nil
+}
+
+func fallbackRunsFromRun(run props.RichRun, fallbackReady bool) []props.RichRun {
+ if !fallbackReady || run.Text == "" || run.Image != nil || run.ForceBreak {
+ return []props.RichRun{run}
+ }
+ if !fallbackCandidateFamily(run.Family) || !textContainsNonWinAnsi(run.Text) {
+ return []props.RichRun{run}
+ }
+
+ out := make([]props.RichRun, 0, 3)
+ var b strings.Builder
+ currentFallback := false
+ haveSegment := false
+ flush := func() {
+ if !haveSegment {
+ return
+ }
+ segment := run
+ segment.Text = b.String()
+ if currentFallback {
+ segment.Family = fallbackFontFamily
+ }
+ out = append(out, segment)
+ b.Reset()
+ haveSegment = false
+ }
+ for _, r := range run.Text {
+ useFallback := !canEncodeWinAnsiRune(r)
+ if haveSegment && useFallback != currentFallback {
+ flush()
+ }
+ currentFallback = useFallback
+ haveSegment = true
+ b.WriteRune(r)
+ }
+ flush()
+ normalizeSplitRunMargins(out)
+ return out
+}
+
+func normalizeSplitRunMargins(runs []props.RichRun) {
+ if len(runs) <= 1 {
+ return
+ }
+ last := len(runs) - 1
+ for i := range runs {
+ if i > 0 {
+ runs[i].InlineMarginLeft = 0
+ }
+ if i < last {
+ runs[i].InlineMarginRight = 0
+ }
+ }
+}
+
+func fallbackCandidateFamily(family string) bool {
+ switch strings.ToLower(strings.TrimSpace(family)) {
+ case "",
+ consts.FontFamilyArial,
+ consts.FontFamilyHelvetica,
+ consts.FontFamilyCourier,
+ consts.FontFamilySymbol,
+ consts.FontFamilyZapBats,
+ "sans",
+ "sans-serif",
+ "serif",
+ "monospace",
+ "courier new",
+ "times",
+ "times new roman":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/pkg/html/translate/fallback_font_test.go b/pkg/html/translate/fallback_font_test.go
new file mode 100644
index 00000000..ea11922a
--- /dev/null
+++ b/pkg/html/translate/fallback_font_test.go
@@ -0,0 +1,127 @@
+package translate
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+)
+
+func TestLoadFallbackFont_WhenBodyHasNonWinAnsiText_LoadsThroughResolver(t *testing.T) {
+ t.Parallel()
+
+ doc, err := dom.Parse(`Hello 中
`)
+ require.NoError(t, err)
+ body := findBody(doc)
+ require.NotNil(t, body)
+
+ var requested string
+ tr := &translator{fallbackFontPath: "fonts/fallback.ttf"}
+ tr.loadFallbackFontIfNeeded(context.Background(), body, func(path string) ([]byte, error) {
+ requested = path
+ return []byte("font bytes"), nil
+ })
+
+ assert.Equal(t, "fonts/fallback.ttf", requested)
+ require.NotEmpty(t, tr.loadedFonts)
+ assert.Equal(t, fallbackFontFamily, tr.loadedFonts[0].family)
+ assert.Equal(t, []byte("font bytes"), tr.loadedFonts[0].bytes)
+ assert.True(t, tr.fallbackFontReady)
+}
+
+func TestLoadFallbackFont_WhenTextIsWinAnsi_DoesNotLoad(t *testing.T) {
+ t.Parallel()
+
+ doc, err := dom.Parse(`Hello cafe
`)
+ require.NoError(t, err)
+ body := findBody(doc)
+ require.NotNil(t, body)
+
+ called := false
+ tr := &translator{fallbackFontPath: "fonts/fallback.ttf"}
+ tr.loadFallbackFontIfNeeded(context.Background(), body, func(string) ([]byte, error) {
+ called = true
+ return []byte("font bytes"), nil
+ })
+
+ assert.False(t, called)
+ assert.Empty(t, tr.loadedFonts)
+ assert.False(t, tr.fallbackFontReady)
+}
+
+func TestLoadFallbackFont_WhenResolverFails_ReportsStrictAssetError(t *testing.T) {
+ t.Parallel()
+
+ doc, err := dom.Parse(`中
`)
+ require.NoError(t, err)
+ body := findBody(doc)
+ require.NotNil(t, body)
+
+ wantErr := errors.New("missing font")
+ tr := &translator{
+ fallbackFontPath: "missing.ttf",
+ strictAssets: true,
+ }
+ tr.loadFallbackFontIfNeeded(context.Background(), body, func(string) ([]byte, error) {
+ return nil, wantErr
+ })
+
+ assert.Empty(t, tr.loadedFonts)
+ assert.False(t, tr.fallbackFontReady)
+ require.Error(t, tr.strictAssetError())
+ assert.ErrorIs(t, tr.strictAssetError(), wantErr)
+}
+
+func TestFallbackFontSplitsMixedWinAnsiAndNonWinAnsiRuns(t *testing.T) {
+ t.Parallel()
+
+ doc, err := dom.Parse(`Hello 中 world
`)
+ require.NoError(t, err)
+ var target *dom.Node
+ doc.Walk(func(n *dom.Node) bool {
+ if n.Tag() == "p" {
+ target = n
+ return false
+ }
+ return true
+ })
+ require.NotNil(t, target)
+
+ tr := &translator{fallbackFontReady: true}
+ runs := tr.inlineRunsStyled(target, nil)
+
+ require.Len(t, runs, 3)
+ assert.Equal(t, "Hello ", runs[0].Text)
+ assert.Equal(t, "", runs[0].Family)
+ assert.Equal(t, "中", runs[1].Text)
+ assert.Equal(t, fallbackFontFamily, runs[1].Family)
+ assert.Equal(t, " world", runs[2].Text)
+ assert.Equal(t, "", runs[2].Family)
+}
+
+func TestFallbackFontDoesNotOverrideAuthorFontFamily(t *testing.T) {
+ t.Parallel()
+
+ doc, err := dom.Parse(`中
`)
+ require.NoError(t, err)
+ var target *dom.Node
+ doc.Walk(func(n *dom.Node) bool {
+ if n.Tag() == "p" {
+ target = n
+ return false
+ }
+ return true
+ })
+ require.NotNil(t, target)
+
+ tr := &translator{fallbackFontReady: true}
+ style := computeNodeStyle(nil, target, nil)
+ runs := tr.inlineRunsStyled(target, blockInlineStyle(style))
+
+ require.Len(t, runs, 1)
+ assert.Equal(t, "中", runs[0].Text)
+ assert.Equal(t, "Custom", runs[0].Family)
+}
diff --git a/pkg/html/translate/flex.go b/pkg/html/translate/flex.go
index 86600de6..e28d733d 100644
--- a/pkg/html/translate/flex.go
+++ b/pkg/html/translate/flex.go
@@ -412,6 +412,19 @@ func rowFromCols(cols []core.Col) core.Row {
return r
}
+// rowFromColsWithHeight is rowFromCols with an explicit row height in mm.
+// A non-positive height keeps the default auto-height behaviour.
+func rowFromColsWithHeight(cols []core.Col, heightMM float64) core.Row {
+ if heightMM <= 0 {
+ return rowFromCols(cols)
+ }
+ r := row.New(heightMM)
+ for _, c := range cols {
+ r = r.Add(c)
+ }
+ return r
+}
+
// gapCols converts a CSS gap (in mm) to integer spacer cols, clamped to half the grid.
// Uses the translator's contentWidthMM, defaulting to 170mm (A4 with 20mm L+R margins).
func (tr *translator) gapCols(gapMM float64, gridSize, itemCount int) int {
diff --git a/pkg/html/translate/flex_inline_axis.go b/pkg/html/translate/flex_inline_axis.go
new file mode 100644
index 00000000..ff69259d
--- /dev/null
+++ b/pkg/html/translate/flex_inline_axis.go
@@ -0,0 +1,69 @@
+package translate
+
+import (
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/tree/node"
+)
+
+// inlineAxisBox aligns a child component along the inline (horizontal) axis
+// of its cell — the counterpart to crossAxisBox for justify-items alignment.
+// The child renders at the given intrinsic width; the leftover cell width is
+// distributed according to align (flex-start / center / flex-end).
+type inlineAxisBox struct {
+ child core.Component
+ align string
+ width float64 // mm; intrinsic child width
+}
+
+func (b *inlineAxisBox) SetConfig(config *entity.Config) {
+ if b.child != nil {
+ b.child.SetConfig(config)
+ }
+}
+
+func (b *inlineAxisBox) GetStructure() *node.Node[core.Structure] {
+ str := core.Structure{
+ Type: "inline_axis_box",
+ Details: map[string]any{"align": b.align, "width": b.width},
+ }
+ n := node.New(str)
+ if b.child != nil {
+ n.AddNext(b.child.GetStructure())
+ }
+ return n
+}
+
+func (b *inlineAxisBox) GetHeight(provider core.Provider, cell *entity.Cell) float64 {
+ if b.child == nil {
+ return 0
+ }
+ inner := b.innerCell(cell)
+ return b.child.GetHeight(provider, &inner)
+}
+
+func (b *inlineAxisBox) Render(provider core.Provider, cell *entity.Cell) {
+ if b.child == nil {
+ return
+ }
+ inner := b.innerCell(cell)
+ b.child.Render(provider, &inner)
+}
+
+func (b *inlineAxisBox) innerCell(cell *entity.Cell) entity.Cell {
+ inner := cell.Copy()
+ if b.width <= 0 || b.width >= cell.Width {
+ return inner
+ }
+ switch b.align {
+ case flexAlignCenter:
+ inner.X += (cell.Width - b.width) / 2
+ case flexAlignEnd:
+ inner.X += cell.Width - b.width
+ case flexAlignStart:
+ default:
+ return inner
+ }
+ inner.Width = b.width
+ return inner
+}
diff --git a/pkg/html/translate/fontface.go b/pkg/html/translate/fontface.go
index 4592ddc0..7ac3beae 100644
--- a/pkg/html/translate/fontface.go
+++ b/pkg/html/translate/fontface.go
@@ -16,6 +16,7 @@ import (
// at render time.
type loadedFont struct {
family string
+ style fontstyle.Type
bytes []byte
}
@@ -45,7 +46,11 @@ func (f *fontRegistration) Render(provider core.Provider, _ *entity.Cell) {
return
}
if lfp, ok := provider.(core.LateFontProvider); ok {
- lfp.RegisterFont(f.font.family, fontstyle.Normal, f.font.bytes)
+ style := f.font.style
+ if style == "" {
+ style = fontstyle.Normal
+ }
+ lfp.RegisterFont(f.font.family, style, f.font.bytes)
f.done = true
}
}
@@ -62,15 +67,17 @@ func (tr *translator) registerFontFaces(resolver StylesheetResolver) {
resolver = safeDefaultStylesheetResolver
}
for _, face := range tr.sheet.fontFaces {
- data, ok := safeLoadStylesheet(resolver, face.srcURL)
- if !ok {
+ data, err := safeLoadStylesheetErr(resolver, face.srcURL)
+ if err != nil {
if tr.unsupportedHandler != nil {
tr.unsupportedHandler("font-face.skipped", face.srcURL)
}
+ tr.reportAssetError("@font-face", face.srcURL, err)
continue
}
tr.loadedFonts = append(tr.loadedFonts, loadedFont{
family: face.family,
+ style: fontstyle.Normal,
bytes: data,
})
}
diff --git a/pkg/html/translate/forms.go b/pkg/html/translate/forms.go
new file mode 100644
index 00000000..d8e30eba
--- /dev/null
+++ b/pkg/html/translate/forms.go
@@ -0,0 +1,403 @@
+package translate
+
+import (
+ "context"
+ "maps"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/avdoseferovic/paper/pkg/components/col"
+ "github.com/avdoseferovic/paper/pkg/components/richtext"
+ "github.com/avdoseferovic/paper/pkg/components/row"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/html/css"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+const (
+ fieldsetDefaultBorderWidth = 0.75 * 0.352778
+ fieldsetDefaultBorderRadius = 3 * 0.352778
+ fieldsetDefaultPadding = 8 * 0.352778
+)
+
+type formControlKind int
+
+const (
+ formControlText formControlKind = iota
+ formControlButton
+ formControlSelect
+ formControlTextarea
+ formControlChoice
+)
+
+const (
+ inputTypeText = "text"
+ inputTypeCheckbox = "checkbox"
+ inputTypeRadio = "radio"
+ inputTypeSubmit = "submit"
+ inputTypeReset = "reset"
+ inputTypeButton = "button"
+ inputTypePassword = "password"
+)
+
+func (tr *translator) formControlRowsContext(ctx context.Context, n *dom.Node, style *css.ComputedStyle) []core.Row {
+ runCtx := tr.styledRunContextContext(ctx, style)
+ runs, handled := formControlRuns(n, runCtx)
+ if !handled || len(runs) == 0 {
+ return nil
+ }
+ rtProp := richTextPropsFromStyle(style)
+ rtProp.Top, rtProp.Right, rtProp.Bottom, rtProp.Left = 0, 0, 0, 0
+ rt := richtext.New(runs, rtProp)
+ if tr.anchorReg != nil {
+ rt.WithAnchorRegistry(tr.anchorReg)
+ }
+ return []core.Row{row.New().Add(col.New().Add(rt))}
+}
+
+func (tr *translator) fieldsetRows(ctx context.Context, n *dom.Node, style *css.ComputedStyle) []core.Row {
+ fieldsetStyle := fieldsetStyleWithDefaults(style)
+ rows := tr.fieldsetChildRows(ctx, n, fieldsetStyle)
+ return []core.Row{tr.buildContainerRowContext(ctx, fieldsetStyle, rows)}
+}
+
+func (tr *translator) fieldsetChildRows(ctx context.Context, n *dom.Node, style *css.ComputedStyle) []core.Row {
+ var rows []core.Row
+ for _, child := range n.Children() {
+ if isWhitespaceNode(child) {
+ continue
+ }
+ if child.Tag() == tagLegend {
+ rows = append(rows, tr.legendRows(ctx, child, style)...)
+ continue
+ }
+ rows = append(rows, tr.blockRowsWithParent(ctx, child, style)...)
+ }
+ return rows
+}
+
+func (tr *translator) legendRows(ctx context.Context, n *dom.Node, parentStyle *css.ComputedStyle) []core.Row {
+ style := tr.computeBlockStyle(n, parentStyle)
+ if strings.TrimSpace(style.FontWeight) == "" {
+ style = cloneComputedStyle(style)
+ style.FontWeight = "bold"
+ }
+ return []core.Row{tr.paragraphRowStyledContext(ctx, n, style)}
+}
+
+func fieldsetStyleWithDefaults(style *css.ComputedStyle) *css.ComputedStyle {
+ style = cloneComputedStyle(style)
+ if style == nil {
+ style = css.NewComputedStyle()
+ }
+ if style.PaddingTop == 0 && style.PaddingRight == 0 && style.PaddingBottom == 0 && style.PaddingLeft == 0 {
+ style.PaddingTop = fieldsetDefaultPadding
+ style.PaddingRight = fieldsetDefaultPadding
+ style.PaddingBottom = fieldsetDefaultPadding
+ style.PaddingLeft = fieldsetDefaultPadding
+ }
+ if !fieldsetHasVisibleBorder(style) && !fieldsetBorderSuppressed(style) {
+ gray := css.NewRGBColor(150, 150, 150)
+ style.BorderTopWidth = fieldsetDefaultBorderWidth
+ style.BorderRightWidth = fieldsetDefaultBorderWidth
+ style.BorderBottomWidth = fieldsetDefaultBorderWidth
+ style.BorderLeftWidth = fieldsetDefaultBorderWidth
+ style.BorderTopStyle = cssValueSolid
+ style.BorderRightStyle = cssValueSolid
+ style.BorderBottomStyle = cssValueSolid
+ style.BorderLeftStyle = cssValueSolid
+ style.BorderTopColor = &gray
+ style.BorderRightColor = cloneCSSColor(&gray)
+ style.BorderBottomColor = cloneCSSColor(&gray)
+ style.BorderLeftColor = cloneCSSColor(&gray)
+ }
+ if style.BorderRadius == 0 &&
+ style.BorderRadiusTopLeft == 0 &&
+ style.BorderRadiusTopRight == 0 &&
+ style.BorderRadiusBottomRight == 0 &&
+ style.BorderRadiusBottomLeft == 0 {
+ style.BorderRadius = fieldsetDefaultBorderRadius
+ }
+ return style
+}
+
+func cloneComputedStyle(style *css.ComputedStyle) *css.ComputedStyle {
+ if style == nil {
+ return nil
+ }
+ clone := *style
+ clone.Color = cloneCSSColor(style.Color)
+ clone.BackgroundColor = cloneCSSColor(style.BackgroundColor)
+ clone.BorderTopColor = cloneCSSColor(style.BorderTopColor)
+ clone.BorderRightColor = cloneCSSColor(style.BorderRightColor)
+ clone.BorderBottomColor = cloneCSSColor(style.BorderBottomColor)
+ clone.BorderLeftColor = cloneCSSColor(style.BorderLeftColor)
+ clone.TextDecorationColor = cloneCSSColor(style.TextDecorationColor)
+ clone.OutlineColor = cloneCSSColor(style.OutlineColor)
+ clone.BackgroundGradient = style.BackgroundGradient
+ clone.TextShadow = cloneCSSShadow(style.TextShadow)
+ clone.TextShadows = cloneCSSShadows(style.TextShadows)
+ clone.BoxShadow = cloneCSSShadows(style.BoxShadow)
+ if len(style.Vars) > 0 {
+ clone.Vars = make(map[string]string, len(style.Vars))
+ maps.Copy(clone.Vars, style.Vars)
+ }
+ return &clone
+}
+
+func fieldsetHasVisibleBorder(style *css.ComputedStyle) bool {
+ if style == nil {
+ return false
+ }
+ return visibleBorderSide(style.BorderTopWidth, style.BorderTopStyle) ||
+ visibleBorderSide(style.BorderRightWidth, style.BorderRightStyle) ||
+ visibleBorderSide(style.BorderBottomWidth, style.BorderBottomStyle) ||
+ visibleBorderSide(style.BorderLeftWidth, style.BorderLeftStyle)
+}
+
+func fieldsetBorderSuppressed(style *css.ComputedStyle) bool {
+ if style == nil {
+ return false
+ }
+ return borderStyleSuppressesDefault(style.BorderTopStyle) &&
+ borderStyleSuppressesDefault(style.BorderRightStyle) &&
+ borderStyleSuppressesDefault(style.BorderBottomStyle) &&
+ borderStyleSuppressesDefault(style.BorderLeftStyle)
+}
+
+func borderStyleSuppressesDefault(value string) bool {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case cssValueNone, cssValueHidden:
+ return true
+ default:
+ return false
+ }
+}
+
+func formControlRuns(n *dom.Node, ctx runContext) ([]props.RichRun, bool) {
+ if n == nil {
+ return nil, false
+ }
+ switch n.Tag() {
+ case tagInput:
+ return inputControlRuns(n, ctx)
+ case tagButton:
+ text := strings.TrimSpace(n.TextContent())
+ if text == "" {
+ text = "Button"
+ }
+ return []props.RichRun{formControlRun(n, text, ctx, formControlButton, false)}, true
+ case tagSelect:
+ return []props.RichRun{formControlRun(n, selectControlText(n), ctx, formControlSelect, false)}, true
+ case tagTextArea:
+ text, placeholder := textareaControlText(n)
+ return []props.RichRun{formControlRun(n, text, ctx, formControlTextarea, placeholder)}, true
+ default:
+ return nil, false
+ }
+}
+
+func inputControlRuns(n *dom.Node, ctx runContext) ([]props.RichRun, bool) {
+ inputType := strings.ToLower(strings.TrimSpace(n.Attr("type")))
+ if inputType == "" {
+ inputType = inputTypeText
+ }
+ switch inputType {
+ case cssValueHidden:
+ return nil, true
+ case inputTypeCheckbox:
+ if hasAttr(n, "checked") {
+ return []props.RichRun{formControlRun(n, "☑", ctx, formControlChoice, false)}, true
+ }
+ return []props.RichRun{formControlRun(n, "☐", ctx, formControlChoice, false)}, true
+ case inputTypeRadio:
+ if hasAttr(n, "checked") {
+ return []props.RichRun{formControlRun(n, "◉", ctx, formControlChoice, false)}, true
+ }
+ return []props.RichRun{formControlRun(n, "○", ctx, formControlChoice, false)}, true
+ case inputTypeSubmit:
+ return []props.RichRun{formControlRun(n, inputValueOrDefault(n, "Submit"), ctx, formControlButton, false)}, true
+ case inputTypeReset:
+ return []props.RichRun{formControlRun(n, inputValueOrDefault(n, "Reset"), ctx, formControlButton, false)}, true
+ case inputTypeButton:
+ return []props.RichRun{formControlRun(n, inputValueOrDefault(n, "Button"), ctx, formControlButton, false)}, true
+ case inputTypePassword:
+ text, placeholder := inputText(n)
+ if !placeholder && text != "" {
+ text = strings.Repeat("•", utf8.RuneCountInString(text))
+ }
+ return []props.RichRun{formControlRun(n, text, ctx, formControlText, placeholder)}, true
+ default:
+ text, placeholder := inputText(n)
+ return []props.RichRun{formControlRun(n, text, ctx, formControlText, placeholder)}, true
+ }
+}
+
+func inputValueOrDefault(n *dom.Node, fallback string) string {
+ if value := strings.TrimSpace(n.Attr("value")); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func inputText(n *dom.Node) (string, bool) {
+ if value := n.Attr("value"); value != "" {
+ return value, false
+ }
+ if placeholder := n.Attr("placeholder"); placeholder != "" {
+ return placeholder, true
+ }
+ return "", false
+}
+
+func textareaControlText(n *dom.Node) (string, bool) {
+ text := strings.TrimSpace(n.TextContent())
+ if text != "" {
+ return text, false
+ }
+ if placeholder := n.Attr("placeholder"); placeholder != "" {
+ return placeholder, true
+ }
+ return "", false
+}
+
+func selectControlText(n *dom.Node) string {
+ first, selected := selectOptionText(n.Children())
+ text := selected
+ if text == "" {
+ text = first
+ }
+ if text == "" {
+ return "▾"
+ }
+ return text + " ▾"
+}
+
+func selectOptionText(nodes []*dom.Node) (string, string) {
+ first := ""
+ selected := ""
+ for _, child := range nodes {
+ switch child.Tag() {
+ case tagOption:
+ text := strings.TrimSpace(child.TextContent())
+ if first == "" {
+ first = text
+ }
+ if hasAttr(child, "selected") {
+ return first, text
+ }
+ case tagOptgroup:
+ groupFirst, groupSelected := selectOptionText(child.Children())
+ if first == "" {
+ first = groupFirst
+ }
+ if groupSelected != "" {
+ return first, groupSelected
+ }
+ default:
+ continue
+ }
+ }
+ return first, selected
+}
+
+func formControlRun(n *dom.Node, text string, ctx runContext, kind formControlKind, placeholder bool) props.RichRun {
+ runCtx := ctx
+ if placeholder && ctx.pseudoResolver != nil {
+ if pseudo := ctx.pseudoResolver(n, ctx.style, "placeholder"); pseudo != nil {
+ runCtx.style = pseudo
+ }
+ }
+ run := richRunFromContext(text, runCtx)
+ applyFormControlDefaults(&run, kind, ctx.style, placeholder)
+ return run
+}
+
+func applyFormControlDefaults(run *props.RichRun, kind formControlKind, style *css.ComputedStyle, placeholder bool) {
+ if run == nil {
+ return
+ }
+ if placeholder && run.Color == nil {
+ run.Color = &props.Color{Red: 117, Green: 117, Blue: 117}
+ }
+ if kind == formControlChoice {
+ return
+ }
+ if run.Background == nil {
+ switch kind {
+ case formControlButton:
+ run.Background = &props.Color{Red: 245, Green: 245, Blue: 245}
+ case formControlText, formControlSelect, formControlTextarea:
+ run.Background = props.CloneColor(&props.WhiteColor)
+ case formControlChoice:
+ }
+ }
+ if run.BorderColor == nil {
+ run.BorderColor = &props.Color{Red: 150, Green: 150, Blue: 150}
+ }
+ if run.BorderWidth == 0 {
+ run.BorderWidth = 0.25
+ }
+ if run.BgRadius == 0 {
+ run.BgRadius = 0.8
+ }
+ applyFormControlPadding(run, kind, style)
+ applyFormControlDimensions(run, kind, style)
+}
+
+func applyFormControlPadding(run *props.RichRun, kind formControlKind, style *css.ComputedStyle) {
+ padX, padY := 2.0, 1.0
+ if kind == formControlButton {
+ padX, padY = 2.5, 1.0
+ }
+ left, right, top, bottom := padX, padX, padY, padY
+ if style != nil {
+ if style.PaddingLeft > 0 {
+ left = style.PaddingLeft
+ }
+ if style.PaddingRight > 0 {
+ right = style.PaddingRight
+ }
+ if style.PaddingTop > 0 {
+ top = style.PaddingTop
+ }
+ if style.PaddingBottom > 0 {
+ bottom = style.PaddingBottom
+ }
+ }
+ if run.BgPadLeft == 0 {
+ run.BgPadLeft = left
+ }
+ if run.BgPadRight == 0 {
+ run.BgPadRight = right
+ }
+ if run.BgPadY == 0 {
+ run.BgPadY = max(top, bottom)
+ }
+}
+
+func applyFormControlDimensions(run *props.RichRun, kind formControlKind, style *css.ComputedStyle) {
+ if style != nil {
+ if style.Width > 0 && run.InlineBoxWidth == 0 {
+ run.InlineBoxWidth = cssContentBoxWidth(style)
+ }
+ if style.Height > 0 && run.InlineBoxHeight == 0 {
+ run.InlineBoxHeight = cssContentBoxHeight(style)
+ }
+ }
+ if run.InlineBoxWidth == 0 {
+ switch kind {
+ case formControlText:
+ run.InlineBoxWidth = 40
+ case formControlSelect:
+ run.InlineBoxWidth = 35
+ case formControlTextarea:
+ run.InlineBoxWidth = 70
+ case formControlButton, formControlChoice:
+ }
+ }
+ if kind == formControlTextarea && run.InlineBoxHeight == 0 {
+ run.InlineBoxHeight = 16
+ }
+}
diff --git a/pkg/html/translate/forms_test.go b/pkg/html/translate/forms_test.go
new file mode 100644
index 00000000..1eb348b1
--- /dev/null
+++ b/pkg/html/translate/forms_test.go
@@ -0,0 +1,255 @@
+package translate
+
+import (
+ "context"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/consts"
+ "github.com/avdoseferovic/paper/pkg/consts/fontstyle"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/html/css"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+)
+
+func TestTranslate_FormControlsRenderVisualRows(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, `
+
+
+
+
+
+
+
+
+ `)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+
+ assert.Equal(t, []string{
+ "Hello",
+ "••••••",
+ "☑",
+ "○",
+ "Submit",
+ "Save",
+ "Final ▾",
+ "Notes",
+ }, richTextValues(rows))
+}
+
+func TestTranslate_HiddenInputsAreSkippedAndPlaceholdersRender(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, `
+
+
+
+ `)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+
+ assert.Equal(t, []string{"Email", "Message"}, richTextValues(rows))
+}
+
+func TestTranslate_FormControlsRenderInsideParagraphs(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, `Name
`)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ require.Len(t, rows, 3)
+
+ assert.Equal(t, []string{"Name Ada Save"}, richTextValues(rows))
+}
+
+func TestTranslate_FormAndLabelRenderChildControls(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, ``)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ require.Len(t, rows, 1)
+
+ assert.Equal(t, []string{"Name Ada Go"}, richTextValues(rows))
+}
+
+func TestTranslate_FieldsetRendersBorderedContainerWithLegend(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, ``)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ assertDefaultFieldsetMargins(t, rows)
+
+ fieldset := requireFieldsetContainer(t, rows)
+ container := fieldset.container
+ require.NotNil(t, container)
+ require.NotNil(t, container.style)
+ assert.Equal(t, consts.LineStyleSolid, container.style.BorderTopStyle)
+ assert.InDelta(t, 0.75*0.352778, container.style.BorderTopThickness, 0.001)
+ assert.InDelta(t, 3*0.352778, container.style.BorderRadius, 0.001)
+ assert.InDelta(t, (8+0.75)*0.352778, container.paddingLeft, 0.001)
+ assert.Equal(t, []string{"Personal Info", "Name: John"}, richTextValues(rows))
+}
+
+func TestTranslate_FieldsetLegendDefaultsMatchFolio(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, ``)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ assertDefaultFieldsetMargins(t, rows)
+ setBlockTagTestConfig(rows)
+
+ provider := &blockTagRecordingProvider{}
+ for _, r := range rows {
+ r.Render(provider, entity.Cell{Width: 100, Height: 100})
+ }
+
+ legend := requireTextCall(t, provider.texts, "Personal Info")
+ assert.Equal(t, fontstyle.Bold, legend.prop.Style)
+ assert.InDelta(t, 4*0.352778, legend.prop.Bottom, 0.001)
+}
+
+func TestTranslate_FieldsetLegendCSSOverridesDefaults(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, ``)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ setBlockTagTestConfig(rows)
+
+ provider := &blockTagRecordingProvider{}
+ for _, r := range rows {
+ r.Render(provider, entity.Cell{Width: 100, Height: 100})
+ }
+
+ legend := requireTextCall(t, provider.texts, "Login")
+ assert.Equal(t, fontstyle.Normal, legend.prop.Style)
+ assert.Equal(t, 0.0, legend.prop.Bottom)
+}
+
+func TestTranslate_FieldsetCSSOverridesDefaultChrome(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, ``)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+
+ fieldset := requireFieldsetContainer(t, rows)
+ container := fieldset.container
+ require.NotNil(t, container)
+ require.NotNil(t, container.style)
+ assert.InDelta(t, 2.0, container.style.BorderTopThickness, 0.001)
+ assert.InDelta(t, 4.0, container.style.BorderRadiusTopLeft, 0.001)
+ assert.InDelta(t, 7.0, container.paddingLeft, 0.001)
+ require.NotNil(t, container.style.BorderTopColor)
+ assert.Equal(t, 255, container.style.BorderTopColor.Red)
+}
+
+func TestTranslate_SelectUsesSelectedOptionInsideOptgroup(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, ``)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+
+ assert.Equal(t, []string{"Two ▾"}, richTextValues(rows))
+}
+
+func TestFormControlRuns_UsesPlaceholderPseudoStyle(t *testing.T) {
+ t.Parallel()
+
+ doc := parseInternalDoc(t, ``)
+ input := firstTestNodeByTag(t, doc, "input")
+ sheet := parseStylesheet(`input::placeholder { color: #ff0000; font-style: italic }`)
+ tr := &translator{sheet: sheet}
+ style := computeNodeStyle(sheet, input, css.NewComputedStyle())
+ ctx := tr.styledRunContext(style)
+
+ runs, handled := formControlRuns(input, ctx)
+ require.True(t, handled)
+ require.Len(t, runs, 1)
+
+ assert.Equal(t, "Email", runs[0].Text)
+ require.NotNil(t, runs[0].Color)
+ assert.Equal(t, 255, runs[0].Color.Red)
+ assert.Equal(t, 0, runs[0].Color.Green)
+ assert.Equal(t, 0, runs[0].Color.Blue)
+ assert.Equal(t, fontstyle.Italic, runs[0].Style)
+}
+
+func firstTestNodeByTag(t *testing.T, doc *dom.Document, tag string) *dom.Node {
+ t.Helper()
+ var found *dom.Node
+ doc.Walk(func(n *dom.Node) bool {
+ if n.Tag() == tag {
+ found = n
+ return false
+ }
+ return true
+ })
+ require.NotNil(t, found)
+ return found
+}
+
+func requireFieldsetContainer(t *testing.T, rows []core.Row) *splittableContainerRow {
+ t.Helper()
+ for _, r := range rows {
+ if fieldset, ok := r.(*splittableContainerRow); ok {
+ return fieldset
+ }
+ }
+ require.FailNow(t, "expected fieldset container row")
+ return nil
+}
+
+func assertDefaultFieldsetMargins(t *testing.T, rows []core.Row) {
+ t.Helper()
+ wantMargin := 9 * 0.352778
+ var marginSpacers int
+ for _, r := range rows {
+ if spacer, ok := r.(marginSpacerRow); ok {
+ marginSpacers++
+ assert.InDelta(t, wantMargin, spacer.height, 0.001)
+ }
+ }
+ assert.Equal(t, 2, marginSpacers)
+}
+
+func requireTextCall(t *testing.T, calls []blockTagTextCall, text string) blockTagTextCall {
+ t.Helper()
+ for _, call := range calls {
+ if call.text == text {
+ return call
+ }
+ }
+ require.FailNow(t, "expected text call %q", text)
+ return blockTagTextCall{}
+}
diff --git a/pkg/html/translate/grid.go b/pkg/html/translate/grid.go
new file mode 100644
index 00000000..4056e44c
--- /dev/null
+++ b/pkg/html/translate/grid.go
@@ -0,0 +1,480 @@
+package translate
+
+import (
+ "context"
+ "strconv"
+ "strings"
+ "unicode"
+
+ "github.com/avdoseferovic/paper/internal/layout"
+ "github.com/avdoseferovic/paper/pkg/components/col"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/html/css"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+)
+
+type gridTrack struct {
+ fixed float64
+ flex float64
+}
+
+type gridPlacement struct {
+ child *dom.Node
+ style *css.ComputedStyle
+ row int
+ start int
+ end int
+}
+
+func (tr *translator) gridRows(ctx context.Context, n *dom.Node, style *css.ComputedStyle) []core.Row {
+ children, styles := tr.gridItems(n, style)
+ if len(children) == 0 {
+ return nil
+ }
+ gridSize := normalizedFlexGridSize(tr.gridSize)
+ tracks := parseGridTracks(style.GridTemplateColumns, style.FontSize, tr.flexContentWidth())
+ if len(tracks) == 0 {
+ tracks = []gridTrack{{flex: 1}}
+ }
+ columnCount := len(tracks)
+ gapCols := tr.gapCols(style.ColumnGap, gridSize, columnCount)
+ totalGap := gapCols * max(0, columnCount-1)
+ available := gridSize - totalGap
+ if available < columnCount {
+ gapCols = 0
+ totalGap = 0
+ available = gridSize
+ }
+ sizes := gridTrackUnits(tracks, available, tr.flexContentWidth())
+ placements := gridPlacements(children, styles, columnCount, style.GridTemplateAreas)
+ rowTracks := parseGridTracks(style.GridTemplateRows, style.FontSize, style.Height)
+ autoRowTracks := parseGridTracks(style.GridAutoRows, style.FontSize, style.Height)
+ var rows []core.Row
+ for rowIdx := range placements {
+ rowHeight := gridRowTrackHeight(rowIdx, rowTracks, autoRowTracks)
+ r := tr.gridRow(ctx, placements[rowIdx], sizes, gapCols, columnCount, rowHeight, style)
+ if r != nil {
+ if len(rows) > 0 && style.RowGap > 0 {
+ rows = append(rows, spacerRow(style.RowGap))
+ }
+ rows = append(rows, r)
+ }
+ }
+ _ = totalGap
+ return rows
+}
+
+func (tr *translator) gridItems(n *dom.Node, parentStyle *css.ComputedStyle) ([]*dom.Node, []*css.ComputedStyle) {
+ var children []*dom.Node
+ var styles []*css.ComputedStyle
+ for _, child := range n.Children() {
+ if isWhitespaceNode(child) {
+ continue
+ }
+ style := tr.computeBlockStyle(child, parentStyle)
+ if isDisplayNone(child) || style.Display == displayNone {
+ continue
+ }
+ children = append(children, child)
+ styles = append(styles, style)
+ }
+ return children, styles
+}
+
+func (tr *translator) gridRow(
+ ctx context.Context,
+ placements []gridPlacement,
+ sizes []int,
+ gapCols int,
+ columnCount int,
+ rowHeight float64,
+ containerStyle *css.ComputedStyle,
+) core.Row {
+ var cols []core.Col
+ starts := map[int]gridPlacement{}
+ for _, placement := range placements {
+ starts[placement.start] = placement
+ }
+ for track := 0; track < columnCount; {
+ if track > 0 && gapCols > 0 {
+ cols = append(cols, col.New(gapCols))
+ }
+ if placement, ok := starts[track]; ok {
+ err := translationCanceled(ctx)
+ if err != nil {
+ tr.err = err
+ return nil
+ }
+ c := col.New(spannedGridUnits(sizes, placement.start, placement.end, gapCols))
+ if comp := tr.flexItemContent(ctx, placement.child, placement.style); comp != nil {
+ comp = tr.gridItemInlineAxisBox(comp, placement.child, containerStyle, placement.style)
+ comp = flexItemCrossAxisBox(comp, containerStyle, placement.style)
+ c = c.Add(comp)
+ }
+ cols = append(cols, c)
+ track = placement.end
+ continue
+ }
+ cols = append(cols, col.New(sizes[track]))
+ track++
+ }
+ return rowFromColsWithHeight(cols, rowHeight)
+}
+
+func (tr *translator) gridItemInlineAxisBox(
+ child core.Component,
+ n *dom.Node,
+ containerStyle *css.ComputedStyle,
+ itemStyle *css.ComputedStyle,
+) core.Component {
+ if child == nil || containerStyle == nil {
+ return child
+ }
+ align := normalizeGridInlineAxisAlign(containerStyle.JustifyItems)
+ if align == "" {
+ return child
+ }
+ width, ok := tr.estimateFlexOuterBasis(n, itemStyle)
+ if !ok || width <= 0 {
+ return child
+ }
+ return &inlineAxisBox{child: child, align: align, width: width}
+}
+
+func normalizeGridInlineAxisAlign(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case flexLogicalStart:
+ return flexAlignStart
+ case flexLogicalEnd:
+ return flexAlignEnd
+ case flexAlignCenter, flexAlignEnd, flexAlignStart:
+ return strings.ToLower(strings.TrimSpace(value))
+ default:
+ return ""
+ }
+}
+
+func gridPlacements(
+ children []*dom.Node,
+ styles []*css.ComputedStyle,
+ columnCount int,
+ templateAreas [][]string,
+) [][]gridPlacement {
+ var rows [][]gridPlacement
+ var occupied [][]bool
+ ensureRow := func(row int) {
+ for len(occupied) <= row {
+ occupied = append(occupied, make([]bool, columnCount))
+ rows = append(rows, nil)
+ }
+ }
+ for i, child := range children {
+ style := styles[i]
+ row, start, span := explicitGridPlacement(style, columnCount, templateAreas)
+ switch {
+ case start >= 0 && row >= 0 && gridSpanFits(occupied, row, start, span, columnCount):
+ case start >= 0:
+ row = nextGridRowForStart(occupied, start, span, columnCount)
+ default:
+ row, start = nextGridAutoSlot(occupied, span, columnCount)
+ }
+ ensureRow(row)
+ end := min(start+span, columnCount)
+ for colIdx := start; colIdx < end; colIdx++ {
+ occupied[row][colIdx] = true
+ }
+ rows[row] = append(rows[row], gridPlacement{
+ child: child,
+ style: style,
+ row: row,
+ start: start,
+ end: end,
+ })
+ }
+ return rows
+}
+
+func nextGridRowForStart(occupied [][]bool, start, span, columnCount int) int {
+ for row := 0; ; row++ {
+ if gridSpanFits(occupied, row, start, span, columnCount) {
+ return row
+ }
+ }
+}
+
+func explicitGridPlacement(style *css.ComputedStyle, columnCount int, templateAreas [][]string) (int, int, int) {
+ row := -1
+ start := -1
+ span := 1
+ if style == nil {
+ return row, start, span
+ }
+ if areaStart, areaEnd, areaRow, ok := resolveGridArea(style.GridArea, templateAreas); ok {
+ row = areaRow
+ start = areaStart
+ span = areaEnd - areaStart
+ }
+ if style.GridRowStart > 0 {
+ row = style.GridRowStart - 1
+ }
+ if style.GridColumnStart > 0 {
+ start = style.GridColumnStart - 1
+ }
+ if style.GridColumnEnd > 0 {
+ switch {
+ case style.GridColumnStart > 0 && style.GridColumnEnd > style.GridColumnStart:
+ span = style.GridColumnEnd - style.GridColumnStart
+ case style.GridColumnStart == 0:
+ span = style.GridColumnEnd
+ }
+ }
+ if span < 1 {
+ span = 1
+ }
+ if span > columnCount {
+ span = columnCount
+ }
+ if start >= columnCount {
+ start = columnCount - 1
+ }
+ return row, start, span
+}
+
+func resolveGridArea(name string, areas [][]string) (int, int, int, bool) {
+ name = strings.TrimSpace(name)
+ if name == "" || name == "." || len(areas) == 0 {
+ return 0, 0, 0, false
+ }
+ minCol, maxCol := -1, -1
+ minRow := -1
+ for rowIdx, row := range areas {
+ for colIdx, area := range row {
+ if area != name {
+ continue
+ }
+ if minRow < 0 || rowIdx < minRow {
+ minRow = rowIdx
+ }
+ if minCol < 0 || colIdx < minCol {
+ minCol = colIdx
+ }
+ if maxCol < 0 || colIdx > maxCol {
+ maxCol = colIdx
+ }
+ }
+ }
+ if minRow < 0 {
+ return 0, 0, 0, false
+ }
+ return minCol, maxCol + 1, minRow, true
+}
+
+func nextGridAutoSlot(occupied [][]bool, span, columnCount int) (int, int) {
+ if span > columnCount {
+ span = columnCount
+ }
+ for row := 0; ; row++ {
+ for start := 0; start+span <= columnCount; start++ {
+ if gridSpanFits(occupied, row, start, span, columnCount) {
+ return row, start
+ }
+ }
+ }
+}
+
+func gridSpanFits(occupied [][]bool, row, start, span, columnCount int) bool {
+ if row < 0 || start < 0 || span < 1 || start+span > columnCount {
+ return false
+ }
+ if row >= len(occupied) {
+ return true
+ }
+ for colIdx := start; colIdx < start+span; colIdx++ {
+ if occupied[row][colIdx] {
+ return false
+ }
+ }
+ return true
+}
+
+func spannedGridUnits(sizes []int, start, end, gapCols int) int {
+ units := 0
+ for i := start; i < end && i < len(sizes); i++ {
+ units += sizes[i]
+ if i > start {
+ units += gapCols
+ }
+ }
+ return max(units, 1)
+}
+
+func gridRowTrackHeight(rowIdx int, templateRows, autoRows []gridTrack) float64 {
+ if rowIdx < len(templateRows) {
+ return templateRows[rowIdx].fixed
+ }
+ if len(autoRows) == 0 {
+ return 0
+ }
+ autoIdx := (rowIdx - len(templateRows)) % len(autoRows)
+ return autoRows[autoIdx].fixed
+}
+
+func parseGridTracks(value string, fontSize, contentWidth float64) []gridTrack {
+ value = expandGridRepeat(strings.ToLower(strings.TrimSpace(value)))
+ var tracks []gridTrack
+ for _, token := range splitGridTemplate(value) {
+ switch {
+ case token == "", token == cssValueNone:
+ continue
+ case token == cssValueAuto:
+ tracks = append(tracks, gridTrack{flex: 1})
+ case strings.HasSuffix(token, "fr"):
+ flex, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(token, "fr")), 64)
+ if err != nil || flex <= 0 {
+ flex = 1
+ }
+ tracks = append(tracks, gridTrack{flex: flex})
+ default:
+ fixed := css.ParseLengthCtx(token, fontSize, contentWidth)
+ if fixed > 0 {
+ tracks = append(tracks, gridTrack{fixed: fixed})
+ } else {
+ tracks = append(tracks, gridTrack{flex: 1})
+ }
+ }
+ }
+ return tracks
+}
+
+func gridTrackUnits(tracks []gridTrack, available int, contentWidth float64) []int {
+ if len(tracks) == 0 || available <= 0 {
+ return nil
+ }
+ sizes := make([]int, len(tracks))
+ fixedTotal := 0
+ var flexIndices []int
+ var flexWeights []float64
+ for i, track := range tracks {
+ if track.fixed > 0 && contentWidth > 0 {
+ units := max(int(track.fixed/contentWidth*float64(available)+0.5), 1)
+ sizes[i] = units
+ fixedTotal += units
+ continue
+ }
+ weight := track.flex
+ if weight <= 0 {
+ weight = 1
+ }
+ flexIndices = append(flexIndices, i)
+ flexWeights = append(flexWeights, weight)
+ }
+ if fixedTotal > available {
+ weights := make([]float64, len(tracks))
+ for i, size := range sizes {
+ weights[i] = float64(size)
+ }
+ return bumpZerosWithoutOverflow(layout.ProportionalUnits(weights, available), available)
+ }
+ remaining := available - fixedTotal
+ if len(flexIndices) > 0 {
+ flexSizes := bumpZerosWithoutOverflow(layout.ProportionalUnits(flexWeights, remaining), remaining)
+ for i, idx := range flexIndices {
+ sizes[idx] = flexSizes[i]
+ }
+ }
+ return bumpZerosWithoutOverflow(sizes, available)
+}
+
+func expandGridRepeat(value string) string {
+ for {
+ idx := strings.Index(value, "repeat(")
+ if idx < 0 {
+ return value
+ }
+ closeIdx := matchingGridCloseParen(value, idx+len("repeat"))
+ if closeIdx < 0 {
+ return value
+ }
+ inner := value[idx+len("repeat(") : closeIdx]
+ parts := splitGridRepeatArgs(inner)
+ if len(parts) != 2 {
+ return value
+ }
+ count, err := strconv.Atoi(strings.TrimSpace(parts[0]))
+ if err != nil || count <= 0 {
+ return value
+ }
+ repeated := strings.TrimSpace(parts[1])
+ value = value[:idx] + strings.TrimSpace(strings.Repeat(repeated+" ", count)) + value[closeIdx+1:]
+ }
+}
+
+func splitGridRepeatArgs(value string) []string {
+ var out []string
+ depth := 0
+ start := 0
+ for i, r := range value {
+ switch r {
+ case '(':
+ depth++
+ case ')':
+ if depth > 0 {
+ depth--
+ }
+ case ',':
+ if depth == 0 {
+ out = append(out, strings.TrimSpace(value[start:i]))
+ start = i + len(string(r))
+ }
+ }
+ }
+ out = append(out, strings.TrimSpace(value[start:]))
+ return out
+}
+
+func splitGridTemplate(value string) []string {
+ var out []string
+ var b strings.Builder
+ depth := 0
+ flush := func() {
+ token := strings.TrimSpace(b.String())
+ if token != "" {
+ out = append(out, token)
+ }
+ b.Reset()
+ }
+ for _, r := range value {
+ switch {
+ case r == '(':
+ depth++
+ b.WriteRune(r)
+ case r == ')':
+ if depth > 0 {
+ depth--
+ }
+ b.WriteRune(r)
+ case depth == 0 && unicode.IsSpace(r):
+ flush()
+ default:
+ b.WriteRune(r)
+ }
+ }
+ flush()
+ return out
+}
+
+func matchingGridCloseParen(value string, open int) int {
+ depth := 0
+ for i := open; i < len(value); i++ {
+ switch value[i] {
+ case '(':
+ depth++
+ case ')':
+ depth--
+ if depth == 0 {
+ return i
+ }
+ }
+ }
+ return -1
+}
diff --git a/pkg/html/translate/image.go b/pkg/html/translate/image.go
index b6169f6a..8dfe224b 100644
--- a/pkg/html/translate/image.go
+++ b/pkg/html/translate/image.go
@@ -165,6 +165,7 @@ func (tr *translator) imageRowWithSourceAndStyle(n *dom.Node, src string, style
data, ext, err := tr.resolveImage(src)
if err != nil {
tr.unsupported("img.src", err.Error())
+ tr.reportAssetError("image", src, err)
if errors.Is(err, htmllimits.ErrImageTooLarge) {
tr.err = err
}
@@ -259,6 +260,7 @@ func (tr *translator) richImageFromSource(
data, ext, err := tr.resolveImage(src)
if err != nil {
tr.unsupported(unsupportedPrefix+".src", err.Error())
+ tr.reportAssetError("image", src, err)
if errors.Is(err, htmllimits.ErrImageTooLarge) {
tr.err = err
}
@@ -724,6 +726,7 @@ func (tr *translator) backgroundImage(style *css.ComputedStyle) *props.CellBackg
data, ext, err := tr.resolveImage(src)
if err != nil {
tr.unsupported("background-image.src", err.Error())
+ tr.reportAssetError("background-image", src, err)
if errors.Is(err, htmllimits.ErrImageTooLarge) {
tr.err = err
}
diff --git a/pkg/html/translate/important_test.go b/pkg/html/translate/important_test.go
new file mode 100644
index 00000000..8406c5e1
--- /dev/null
+++ b/pkg/html/translate/important_test.go
@@ -0,0 +1,56 @@
+package translate
+
+import (
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+func firstRunColorFromHTML(t *testing.T, htmlStr string) *props.Color {
+ t.Helper()
+ runs := runsFromHTML(t, htmlStr)
+ require.NotEmpty(t, runs)
+ return runs[0].Color
+}
+
+func TestImportantStylesheetBeatsInlineNormal(t *testing.T) {
+ t.Parallel()
+
+ color := firstRunColorFromHTML(t, `hello
`)
+
+ require.NotNil(t, color)
+ assert.Equal(t, 255, color.Red)
+ assert.Equal(t, 0, color.Blue)
+}
+
+func TestImportantInlineBeatsStylesheetImportant(t *testing.T) {
+ t.Parallel()
+
+ color := firstRunColorFromHTML(t, `hello
`)
+
+ require.NotNil(t, color)
+ assert.Equal(t, 0, color.Red)
+ assert.Equal(t, 255, color.Green)
+}
+
+func TestImportantStylesheetBeatsLaterNormalStylesheet(t *testing.T) {
+ t.Parallel()
+
+ color := firstRunColorFromHTML(t, `hello
`)
+
+ require.NotNil(t, color)
+ assert.Equal(t, 255, color.Red)
+ assert.Equal(t, 0, color.Blue)
+}
+
+func TestImportantSuffixIsStrippedFromInlineValue(t *testing.T) {
+ t.Parallel()
+
+ color := firstRunColorFromHTML(t, `hello
`)
+
+ require.NotNil(t, color)
+ assert.Equal(t, 0, color.Red)
+ assert.Equal(t, 255, color.Blue)
+}
diff --git a/pkg/html/translate/inline.go b/pkg/html/translate/inline.go
index 938ebf6d..66f44d4a 100644
--- a/pkg/html/translate/inline.go
+++ b/pkg/html/translate/inline.go
@@ -1,6 +1,8 @@
package translate
import (
+ "context"
+
"github.com/avdoseferovic/paper/pkg/consts/fontstyle"
"github.com/avdoseferovic/paper/pkg/html/css"
"github.com/avdoseferovic/paper/pkg/html/dom"
@@ -30,7 +32,27 @@ func (tr *translator) inlineRuns(n *dom.Node) []props.RichRun {
}
func (tr *translator) inlineRunsStyled(n *dom.Node, style *css.ComputedStyle) []props.RichRun {
- return inlineRunsWithContext(n, tr.styledRunContext(style))
+ return tr.applyFallbackFontRuns(inlineRunsWithContext(n, tr.styledRunContext(style)))
+}
+
+// applyFallbackFontRuns splits runs whose text needs the registered fallback
+// font (see fallbackRunsFromRun). A no-op until the fallback font is loaded.
+func (tr *translator) applyFallbackFontRuns(runs []props.RichRun) []props.RichRun {
+ if tr == nil || !tr.fallbackFontReady {
+ return runs
+ }
+ out := make([]props.RichRun, 0, len(runs))
+ for _, run := range runs {
+ out = append(out, fallbackRunsFromRun(run, true)...)
+ }
+ return out
+}
+
+// styledRunContextContext is styledRunContext for context-aware call paths.
+// The runContext walk itself is synchronous and non-blocking, so ctx is not
+// consulted beyond the caller's own cancellation checks.
+func (tr *translator) styledRunContextContext(_ context.Context, style *css.ComputedStyle) runContext {
+ return tr.styledRunContext(style)
}
func (tr *translator) styledRunContext(style *css.ComputedStyle) runContext {
@@ -155,6 +177,10 @@ func walkInline(n *dom.Node, ctx runContext, runs *[]props.RichRun) {
}
counterScope := next.counters.enter(next.style)
defer next.counters.exit(counterScope)
+ if controlRuns, handled := formControlRuns(n, next); handled {
+ *runs = append(*runs, controlRuns...)
+ return
+ }
if tag == tagPicture && ctx.inlinePicture != nil {
if img, ok := ctx.inlinePicture(n); ok {
run := richRunFromContext("", next)
diff --git a/pkg/html/translate/list.go b/pkg/html/translate/list.go
index d8f28206..03ec77ce 100644
--- a/pkg/html/translate/list.go
+++ b/pkg/html/translate/list.go
@@ -1,6 +1,9 @@
package translate
import (
+ "strconv"
+ "strings"
+
"github.com/avdoseferovic/paper/pkg/components/col"
"github.com/avdoseferovic/paper/pkg/components/htmllist"
"github.com/avdoseferovic/paper/pkg/components/richtext"
@@ -35,10 +38,17 @@ func (tr *translator) listRows(n *dom.Node) []core.Row {
func (tr *translator) buildList(n *dom.Node) *htmllist.HTMLList {
style := htmllist.Bullet
start := 0
+ startSet := false
reversed := false
if n.Tag() == "ol" {
style = listStyleFromType(n.Attr("type"))
- start = atoiOr(n.Attr("start"), 0)
+ if raw := strings.TrimSpace(n.Attr("start")); raw != "" {
+ v, err := strconv.Atoi(raw)
+ if err == nil {
+ start = v
+ startSet = true
+ }
+ }
reversed = hasAttr(n, "reversed")
}
cssStyle := computeNodeStyleRooted(tr.sheet, n, tr.rootStyle)
@@ -56,7 +66,7 @@ func (tr *translator) buildList(n *dom.Node) *htmllist.HTMLList {
if len(items) == 0 {
return nil
}
- return htmllist.New(items, htmllist.Prop{Style: style, Start: start, Reversed: reversed})
+ return htmllist.New(items, htmllist.Prop{Style: style, Start: start, StartSet: startSet, Reversed: reversed})
}
// listStyleFromCSS maps a CSS list-style-type value to an htmllist.StyleType.
@@ -91,13 +101,23 @@ func (tr *translator) buildItem(li *dom.Node, parentStyle *css.ComputedStyle) ht
item := htmllist.Item{}
style := computeNodeStyle(tr.sheet, li, parentStyle)
inlineStyle := blockInlineStyle(style)
+ // Enter the li's counter scope so counter-reset/counter-increment apply
+ // before the ::marker content (e.g. counter(...)) is generated.
+ counterScope := tr.counters.enter(style)
+ defer tr.counters.exit(counterScope)
+ item.Marker = tr.listItemMarker(li, inlineStyle)
// Recursively check for nested ul/ol; collect inline content into runs.
var runs []props.RichRun
for _, c := range li.Children() {
switch c.Tag() {
case "ul", "ol":
+ // Enter the nested list's counter scope so its counter-reset
+ // creates a nested level (needed for counters(...) numbering).
+ subStyle := computeNodeStyle(tr.sheet, c, style)
+ subScope := tr.counters.enter(subStyle)
item.SubList = tr.buildList(c)
+ tr.counters.exit(subScope)
default:
// Use inline walker on each child to flatten its text and styling.
walkInline(c, tr.styledRunContext(inlineStyle), &runs)
@@ -109,6 +129,59 @@ func (tr *translator) buildItem(li *dom.Node, parentStyle *css.ComputedStyle) ht
return item
}
+// listItemMarker resolves the li::marker pseudo rules for an item into an
+// htmllist marker override: custom generated content (content: "…" /
+// counter(...)), suppression (content: none), and text styling (color,
+// font-size, weight, family). Returns nil when no ::marker rule matches.
+func (tr *translator) listItemMarker(li *dom.Node, inlineStyle *css.ComputedStyle) *htmllist.ItemMarker {
+ if tr.sheet == nil || li.RawNode() == nil || !tr.sheet.hasPseudoRulesFor(li.RawNode(), "marker") {
+ return nil
+ }
+ markerStyle := computePseudoNodeStyle(tr.sheet, li, inlineStyle, "marker")
+ marker := &htmllist.ItemMarker{TextProp: markerTextPropFromStyle(markerStyle)}
+ if content := strings.TrimSpace(markerStyle.Content); content != "" {
+ runs, ok := generatedContentRuns(markerStyle.Content, li, tr.styledRunContext(markerStyle))
+ if !ok || len(runs) == 0 {
+ // content: none / normal — suppress the marker entirely.
+ marker.Suppress = true
+ return marker
+ }
+ var b strings.Builder
+ for _, run := range runs {
+ b.WriteString(run.Text)
+ }
+ marker.Text = b.String()
+ }
+ return marker
+}
+
+// markerTextPropFromStyle converts the ::marker pseudo style into per-item
+// text prop overrides. Returns nil when nothing is set.
+func markerTextPropFromStyle(style *css.ComputedStyle) *props.Text {
+ tp := &props.Text{}
+ set := false
+ if style.Color != nil {
+ tp.Color = toPropsColor(style.Color, effectiveOpacity(style))
+ set = true
+ }
+ if style.FontSize > 0 {
+ tp.Size = style.FontSize / 0.352778 // mm → pt
+ set = true
+ }
+ if family := firstFontFamily(style.FontFamily); family != "" {
+ tp.Family = family
+ set = true
+ }
+ if shouldApplyCSSFontStyle(style) {
+ tp.Style = mergeCSSFontStyle("", style.FontWeight, style.FontStyle)
+ set = true
+ }
+ if !set {
+ return nil
+ }
+ return tp
+}
+
func listStyleFromType(t string) htmllist.StyleType {
switch t {
case "a":
diff --git a/pkg/html/translate/list_marker_test.go b/pkg/html/translate/list_marker_test.go
new file mode 100644
index 00000000..6bde8ee0
--- /dev/null
+++ b/pkg/html/translate/list_marker_test.go
@@ -0,0 +1,158 @@
+package translate
+
+import (
+ "context"
+ "testing"
+
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/consts/fontstyle"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+ "github.com/avdoseferovic/paper/pkg/props"
+)
+
+type markerTextCall struct {
+ text string
+ prop *props.Text
+}
+
+type markerRecordingProvider struct {
+ cursorProvider
+ texts []markerTextCall
+}
+
+func (p *markerRecordingProvider) AddText(text string, _ *entity.Cell, prop *props.Text) {
+ p.texts = append(p.texts, markerTextCall{text: text, prop: prop})
+}
+
+func TestTranslate_ListMarkerPseudoColorAndFontSize(t *testing.T) {
+ t.Parallel()
+ doc, err := dom.Parse(``)
+ require.NoError(t, err)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ require.Len(t, rows, 1)
+ setMarkerTestConfig(rows[0])
+
+ provider := &markerRecordingProvider{}
+ rows[0].Render(provider, entity.Cell{Width: 100, Height: 100})
+
+ require.NotEmpty(t, provider.texts)
+ marker := provider.texts[0]
+ assert.Equal(t, "•", marker.text)
+ require.NotNil(t, marker.prop.Color)
+ assert.Equal(t, 0, marker.prop.Color.Red)
+ assert.Equal(t, 255, marker.prop.Color.Green)
+ assert.Equal(t, 0, marker.prop.Color.Blue)
+ assert.Equal(t, 20.0, marker.prop.Size)
+}
+
+func TestTranslate_ListMarkerPseudoContentLiteral(t *testing.T) {
+ t.Parallel()
+ doc, err := dom.Parse(``)
+ require.NoError(t, err)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ require.Len(t, rows, 1)
+ setMarkerTestConfig(rows[0])
+
+ provider := &markerRecordingProvider{}
+ rows[0].Render(provider, entity.Cell{Width: 100, Height: 100})
+
+ require.NotEmpty(t, provider.texts)
+ assert.Equal(t, "→ ", provider.texts[0].text)
+}
+
+func TestTranslate_ListMarkerPseudoContentNoneSuppressesMarker(t *testing.T) {
+ t.Parallel()
+ doc, err := dom.Parse(`- Item
`)
+ require.NoError(t, err)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ require.Len(t, rows, 1)
+ setMarkerTestConfig(rows[0])
+
+ provider := &markerRecordingProvider{}
+ rows[0].Render(provider, entity.Cell{Width: 100, Height: 100})
+
+ for _, call := range provider.texts {
+ assert.NotEqual(t, "1.", call.text)
+ }
+}
+
+func TestTranslate_ListMarkerPseudoContentCounterStyle(t *testing.T) {
+ t.Parallel()
+ doc, err := dom.Parse(`- One
- Two
`)
+ require.NoError(t, err)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ require.Len(t, rows, 1)
+ setMarkerTestConfig(rows[0])
+
+ provider := &markerRecordingProvider{}
+ rows[0].Render(provider, entity.Cell{Width: 100, Height: 100})
+
+ require.Len(t, provider.texts, 4)
+ assert.Equal(t, "I. ", provider.texts[0].text)
+ assert.Equal(t, "II. ", provider.texts[2].text)
+}
+
+func TestTranslate_ListMarkerPseudoContentNestedCounters(t *testing.T) {
+ t.Parallel()
+ doc, err := dom.Parse(`- Alpha
- Beta
- Delta
`)
+ require.NoError(t, err)
+
+ rows, err := Translate(context.Background(), doc)
+ require.NoError(t, err)
+ require.Len(t, rows, 1)
+ setMarkerTestConfig(rows[0])
+
+ provider := &markerRecordingProvider{}
+ rows[0].Render(provider, entity.Cell{Width: 100, Height: 100})
+
+ texts := recordedMarkerTexts(provider)
+ assert.Contains(t, texts, "1. ")
+ assert.Contains(t, texts, "1.1. ")
+ assert.Contains(t, texts, "2. ")
+}
+
+func setMarkerTestConfig(row core.Row) {
+ row.SetConfig(&entity.Config{
+ MaxGridSize: 12,
+ DefaultFont: &props.Font{
+ Family: "Helvetica",
+ Style: fontstyle.Normal,
+ Size: 10,
+ },
+ })
+}
+
+func recordedMarkerTexts(provider *markerRecordingProvider) []string {
+ out := make([]string, len(provider.texts))
+ for i, call := range provider.texts {
+ out[i] = call.text
+ }
+ return out
+}
diff --git a/pkg/html/translate/multicol.go b/pkg/html/translate/multicol.go
new file mode 100644
index 00000000..d0962634
--- /dev/null
+++ b/pkg/html/translate/multicol.go
@@ -0,0 +1,121 @@
+package translate
+
+import (
+ "context"
+
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/html/css"
+ "github.com/avdoseferovic/paper/pkg/html/dom"
+)
+
+func (tr *translator) resolvedColumnCount(style *css.ComputedStyle) int {
+ if style == nil {
+ return 0
+ }
+ if style.ColumnCount > 1 {
+ return style.ColumnCount
+ }
+ if style.ColumnWidth <= 0 {
+ return 0
+ }
+ width := tr.availableContentWidth()
+ if style.Width > 0 {
+ width = style.Width
+ }
+ gap := style.ColumnGap
+ if width <= 0 || style.ColumnWidth <= 0 {
+ return 0
+ }
+ count := int((width + gap) / (style.ColumnWidth + gap))
+ if count > 1 {
+ return count
+ }
+ return 0
+}
+
+func (tr *translator) multiColumnRows(
+ ctx context.Context,
+ n *dom.Node,
+ style *css.ComputedStyle,
+ columnCount int,
+) []core.Row {
+ var out []core.Row
+ var segment []core.Row
+ flushSegment := func() {
+ if len(segment) == 0 {
+ return
+ }
+ out = append(out, tr.multiColumnSegmentRow(style, columnCount, segment))
+ segment = nil
+ }
+
+ for _, child := range n.Children() {
+ err := translationCanceled(ctx)
+ if err != nil {
+ tr.err = err
+ return nil
+ }
+ if isWhitespaceNode(child) {
+ continue
+ }
+ childStyle := tr.computeBlockStyle(child, style)
+ if isDisplayNone(child) || childStyle.Display == displayNone {
+ continue
+ }
+ childRows := tr.blockRowsWithParent(ctx, child, style)
+ if childStyle.ColumnSpan == cssValueAll {
+ flushSegment()
+ out = append(out, childRows...)
+ continue
+ }
+ segment = append(segment, childRows...)
+ }
+ flushSegment()
+ return out
+}
+
+func (tr *translator) multiColumnSegmentRow(
+ style *css.ComputedStyle,
+ columnCount int,
+ rows []core.Row,
+) core.Row {
+ gridSize := normalizedFlexGridSize(tr.gridSize)
+ gapCols := tr.gapCols(style.ColumnGap, gridSize, columnCount)
+ totalGap := gapCols * max(0, columnCount-1)
+ available := gridSize - totalGap
+ if available < columnCount {
+ gapCols = 0
+ available = gridSize
+ }
+
+ weights := make([]float64, columnCount)
+ for i := range weights {
+ weights[i] = 1
+ }
+ sizes := bumpZerosWithoutOverflow(Hamilton(weights, available), available)
+ r := newBalancedMultiColumnRow(columnCount, gapCols, sizes, rows)
+ return newColumnRuleRow(
+ r,
+ columnCount,
+ gapCols,
+ style.ColumnRuleWidth,
+ style.ColumnRuleStyle,
+ toPropsColor(style.ColumnRuleColor, effectiveOpacity(style)),
+ )
+}
+
+func distributeColumnRows(rows []core.Row, columnCount int) [][]core.Row {
+ columns := make([][]core.Row, columnCount)
+ if columnCount <= 0 || len(rows) == 0 {
+ return columns
+ }
+ chunkSize := (len(rows) + columnCount - 1) / columnCount
+ for i, r := range rows {
+ column := i / chunkSize
+ if column >= columnCount {
+ column = columnCount - 1
+ }
+ columns[column] = append(columns[column], r)
+ }
+ return columns
+}
diff --git a/pkg/html/translate/multicol_balanced.go b/pkg/html/translate/multicol_balanced.go
new file mode 100644
index 00000000..29152686
--- /dev/null
+++ b/pkg/html/translate/multicol_balanced.go
@@ -0,0 +1,201 @@
+package translate
+
+import (
+ "github.com/avdoseferovic/paper/internal/layout"
+ "github.com/avdoseferovic/paper/pkg/components/col"
+ "github.com/avdoseferovic/paper/pkg/components/row"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/props"
+ "github.com/avdoseferovic/paper/pkg/tree/node"
+)
+
+type balancedMultiColumnRow struct {
+ columnCount int
+ gapCols int
+ columnSizes []int
+ rows []core.Row
+ config *entity.Config
+ style *props.Cell
+
+ cachedWidth float64
+ cached core.Row
+}
+
+func newBalancedMultiColumnRow(columnCount, gapCols int, columnSizes []int, rows []core.Row) core.Row {
+ return &balancedMultiColumnRow{
+ columnCount: columnCount,
+ gapCols: gapCols,
+ columnSizes: append([]int(nil), columnSizes...),
+ rows: append([]core.Row(nil), rows...),
+ }
+}
+
+func (r *balancedMultiColumnRow) SetConfig(config *entity.Config) {
+ r.config = config
+ for _, child := range r.rows {
+ child.SetConfig(config)
+ }
+ if r.cached != nil {
+ r.cached.SetConfig(config)
+ }
+}
+
+func (r *balancedMultiColumnRow) GetStructure() *node.Node[core.Structure] {
+ str := core.Structure{
+ Type: "multi_column_row",
+ Details: map[string]any{
+ "column_count": r.columnCount,
+ "gap_columns": r.gapCols,
+ },
+ }
+ n := node.New(str)
+ if child := r.cachedOrFallback(); child != nil {
+ n.AddNext(child.GetStructure())
+ }
+ return n
+}
+
+func (r *balancedMultiColumnRow) GetHeight(provider core.Provider, cell *entity.Cell) float64 {
+ child := r.build(provider, cell)
+ if child == nil {
+ return 0
+ }
+ return child.GetHeight(provider, cell)
+}
+
+func (r *balancedMultiColumnRow) Render(provider core.Provider, cell entity.Cell) {
+ child := r.build(provider, &cell)
+ if child == nil {
+ return
+ }
+ child.Render(provider, cell)
+}
+
+func (r *balancedMultiColumnRow) Add(...core.Col) core.Row { return r }
+
+func (r *balancedMultiColumnRow) WithStyle(style *props.Cell) core.Row {
+ r.style = props.CloneCell(style)
+ r.cached = nil
+ r.cachedWidth = 0
+ return r
+}
+
+func (r *balancedMultiColumnRow) GetColumns() []core.Col {
+ child := r.cachedOrFallback()
+ if child == nil {
+ return nil
+ }
+ return child.GetColumns()
+}
+
+func (r *balancedMultiColumnRow) cachedOrFallback() core.Row {
+ if r.cached != nil {
+ return r.cached
+ }
+ return r.build(nil, &entity.Cell{Width: defaultFlexContentWidthMM})
+}
+
+func (r *balancedMultiColumnRow) build(provider core.Provider, cell *entity.Cell) core.Row {
+ if r.columnCount <= 0 {
+ return nil
+ }
+ width := defaultFlexContentWidthMM
+ if cell != nil && cell.Width > 0 {
+ width = cell.Width
+ }
+ if r.cached != nil && r.cachedWidth == width {
+ return r.cached
+ }
+
+ columnWidth := r.measureColumnWidth(width)
+ var columns [][]core.Row
+ if provider == nil {
+ columns = distributeColumnRows(r.rows, r.columnCount)
+ } else {
+ columns = distributeBalancedColumnRows(r.rows, r.columnCount, provider, &entity.Cell{
+ Width: columnWidth,
+ Height: cellHeight(cell),
+ })
+ }
+
+ built := row.New()
+ for i, rows := range columns {
+ if i > 0 && r.gapCols > 0 {
+ built = built.Add(col.New(r.gapCols))
+ }
+ size := 1
+ if i < len(r.columnSizes) && r.columnSizes[i] > 0 {
+ size = r.columnSizes[i]
+ }
+ built = built.Add(col.New(size).Add(newFlexCellContent(rows)))
+ }
+ if r.style != nil {
+ built = built.WithStyle(r.style)
+ }
+ if r.config != nil {
+ built.SetConfig(r.config)
+ }
+ r.cached = built
+ r.cachedWidth = width
+ return built
+}
+
+func (r *balancedMultiColumnRow) measureColumnWidth(width float64) float64 {
+ if width <= 0 {
+ width = defaultFlexContentWidthMM
+ }
+ if len(r.columnSizes) == 0 || r.columnSizes[0] <= 0 {
+ return width / float64(max(r.columnCount, 1))
+ }
+ return layout.UnitWidth(width, r.columnSizes[0], r.gridSize())
+}
+
+func (r *balancedMultiColumnRow) gridSize() int {
+ if r.config == nil {
+ return layout.DefaultGridSize()
+ }
+ return layout.NormalizeGridSize(r.config.MaxGridSize)
+}
+
+func cellHeight(cell *entity.Cell) float64 {
+ if cell == nil {
+ return 0
+ }
+ return cell.Height
+}
+
+func distributeBalancedColumnRows(
+ rows []core.Row,
+ columnCount int,
+ provider core.Provider,
+ measureCell *entity.Cell,
+) [][]core.Row {
+ columns := make([][]core.Row, columnCount)
+ if columnCount <= 0 || len(rows) == 0 {
+ return columns
+ }
+
+ heights := make([]float64, len(rows))
+ total := 0.0
+ for i, r := range rows {
+ heights[i] = r.GetHeight(provider, measureCell)
+ total += heights[i]
+ }
+ if total <= 0 {
+ return distributeColumnRows(rows, columnCount)
+ }
+
+ target := total / float64(columnCount)
+ column := 0
+ columnHeight := 0.0
+ for i, r := range rows {
+ if columnHeight+heights[i] > target && column < columnCount-1 && columnHeight > 0 {
+ column++
+ columnHeight = 0
+ }
+ columns[column] = append(columns[column], r)
+ columnHeight += heights[i]
+ }
+ return columns
+}
diff --git a/pkg/html/translate/multicol_rule.go b/pkg/html/translate/multicol_rule.go
new file mode 100644
index 00000000..4cdb00f5
--- /dev/null
+++ b/pkg/html/translate/multicol_rule.go
@@ -0,0 +1,174 @@
+package translate
+
+import (
+ "strings"
+
+ "github.com/avdoseferovic/paper/internal/layout"
+ "github.com/avdoseferovic/paper/pkg/consts"
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/props"
+ "github.com/avdoseferovic/paper/pkg/tree/node"
+)
+
+type columnRuleRow struct {
+ child core.Row
+ columnCount int
+ gapCols int
+ ruleWidth float64
+ ruleStyle string
+ ruleColor *props.Color
+ config *entity.Config
+}
+
+func newColumnRuleRow(child core.Row, columnCount, gapCols int, ruleWidth float64, ruleStyle string, ruleColor *props.Color) core.Row {
+ if child == nil || columnCount <= 1 || ruleWidth <= 0 || isColumnRuleSuppressed(ruleStyle) {
+ return child
+ }
+ if ruleStyle == "" {
+ ruleStyle = "solid"
+ }
+ return &columnRuleRow{
+ child: child,
+ columnCount: columnCount,
+ gapCols: gapCols,
+ ruleWidth: ruleWidth,
+ ruleStyle: strings.ToLower(strings.TrimSpace(ruleStyle)),
+ ruleColor: props.CloneColor(ruleColor),
+ }
+}
+
+func (r *columnRuleRow) SetConfig(config *entity.Config) {
+ r.config = config
+ if r.child != nil {
+ r.child.SetConfig(config)
+ }
+}
+
+func (r *columnRuleRow) GetStructure() *node.Node[core.Structure] {
+ details := map[string]any{
+ "column_count": r.columnCount,
+ "gap_columns": r.gapCols,
+ "rule_width": r.ruleWidth,
+ "rule_style": r.ruleStyle,
+ }
+ if r.ruleColor != nil {
+ details["rule_color"] = r.ruleColor.ToString()
+ }
+ n := node.New(core.Structure{
+ Type: "column_rule_row",
+ Details: details,
+ })
+ if r.child != nil {
+ n.AddNext(r.child.GetStructure())
+ }
+ return n
+}
+
+func (r *columnRuleRow) GetHeight(provider core.Provider, cell *entity.Cell) float64 {
+ if r.child == nil {
+ return 0
+ }
+ return r.child.GetHeight(provider, cell)
+}
+
+func (r *columnRuleRow) Render(provider core.Provider, cell entity.Cell) {
+ if r.child == nil {
+ return
+ }
+ height := r.child.GetHeight(provider, &cell)
+ renderCell := cell
+ renderCell.Height = height
+ r.child.Render(provider, renderCell)
+ r.renderRules(provider, renderCell)
+}
+
+func (r *columnRuleRow) Add(cols ...core.Col) core.Row {
+ if r.child != nil {
+ r.child = r.child.Add(cols...)
+ }
+ return r
+}
+
+func (r *columnRuleRow) WithStyle(style *props.Cell) core.Row {
+ if r.child != nil {
+ r.child = r.child.WithStyle(style)
+ }
+ return r
+}
+
+func (r *columnRuleRow) GetColumns() []core.Col {
+ if r.child == nil {
+ return nil
+ }
+ return r.child.GetColumns()
+}
+
+func (r *columnRuleRow) renderRules(provider core.Provider, cell entity.Cell) {
+ if provider == nil || cell.Height <= 0 || r.ruleWidth <= 0 || isColumnRuleSuppressed(r.ruleStyle) {
+ return
+ }
+ for _, x := range r.rulePositions(cell.Width) {
+ provider.AddLine(&entity.Cell{
+ X: cell.X + x,
+ Y: cell.Y,
+ Width: 0,
+ Height: cell.Height,
+ }, &props.Line{
+ Color: props.CloneColor(r.ruleColor),
+ Style: cssBorderStyleToLineStyle(r.ruleStyle),
+ Thickness: r.ruleWidth,
+ Orientation: consts.OrientationVertical,
+ OffsetPercent: 0,
+ SizePercent: 100,
+ })
+ }
+}
+
+func (r *columnRuleRow) rulePositions(width float64) []float64 {
+ if r.child == nil || r.columnCount <= 1 || width <= 0 {
+ return nil
+ }
+ cols := r.child.GetColumns()
+ if len(cols) == 0 {
+ return nil
+ }
+ units := make([]int, len(cols))
+ for i, c := range cols {
+ units[i] = c.GetSize()
+ }
+ plan := layout.ManualUnits(units, r.gridSize())
+
+ positions := make([]float64, 0, r.columnCount-1)
+ x := 0.0
+ unitIdx := 0
+ for column := 0; column < r.columnCount-1 && unitIdx < len(plan.Units); column++ {
+ x += layout.UnitWidth(width, plan.Units[unitIdx], plan.GridSize)
+ unitIdx++
+ if r.gapCols > 0 && unitIdx < len(plan.Units) {
+ gapWidth := layout.UnitWidth(width, plan.Units[unitIdx], plan.GridSize)
+ positions = append(positions, x+gapWidth/2)
+ x += gapWidth
+ unitIdx++
+ continue
+ }
+ positions = append(positions, x)
+ }
+ return positions
+}
+
+func (r *columnRuleRow) gridSize() int {
+ if r.config == nil {
+ return layout.DefaultGridSize()
+ }
+ return layout.NormalizeGridSize(r.config.MaxGridSize)
+}
+
+func isColumnRuleSuppressed(style string) bool {
+ switch strings.ToLower(strings.TrimSpace(style)) {
+ case cssValueNone, cssValueHidden:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/pkg/html/translate/options.go b/pkg/html/translate/options.go
index 6a5c0dff..4e331ed6 100644
--- a/pkg/html/translate/options.go
+++ b/pkg/html/translate/options.go
@@ -1,12 +1,18 @@
package translate
import (
+ "net/http"
+
"github.com/avdoseferovic/paper/internal/htmllimits"
)
// Option configures translator behaviour.
type Option func(*translator)
+// URLPolicy vets a URL before any remote fetch. Returning a non-nil error
+// blocks the fetch; the error is wrapped in ErrURLPolicyDenied.
+type URLPolicy func(rawURL string) error
+
// WithGridSize overrides the default 12-column grid size used for flex quantization.
func WithGridSize(n int) Option {
return func(tr *translator) {
@@ -68,6 +74,50 @@ func WithOutlineFromHeadings() Option {
}
}
+// WithStrictAssets makes asset load failures (stylesheet links, @font-face
+// sources, images, background images) surface as errors from Translate.
+// The partially-translated rows are still returned alongside the joined
+// asset errors. Without this option failures warn-and-continue.
+func WithStrictAssets() Option {
+ return func(tr *translator) {
+ tr.strictAssets = true
+ }
+}
+
+// WithRemoteAssets enables http(s) fetching for document-referenced assets
+// (stylesheet links and the fallback font). Off by default: remote URLs are
+// refused so untrusted HTML cannot trigger network access.
+func WithRemoteAssets() Option {
+ return func(tr *translator) {
+ tr.remoteAssets = true
+ }
+}
+
+// WithURLPolicy registers a callback that can veto individual URLs before any
+// remote fetch. Only consulted when remote fetching is enabled.
+func WithURLPolicy(policy URLPolicy) Option {
+ return func(tr *translator) {
+ tr.urlPolicy = policy
+ }
+}
+
+// WithHTTPClient overrides http.DefaultClient for remote asset fetches.
+func WithHTTPClient(client *http.Client) Option {
+ return func(tr *translator) {
+ tr.httpClient = client
+ }
+}
+
+// WithFallbackFontPath registers a font (local path, data: URI, or http(s)
+// URL with WithRemoteAssets) that is loaded on demand when the document
+// contains text outside the WinAnsi (cp1252) repertoire, so such text renders
+// instead of degrading.
+func WithFallbackFontPath(path string) Option {
+ return func(tr *translator) {
+ tr.fallbackFontPath = path
+ }
+}
+
// headingOutlineLevel maps an h1-h6 tag to its outline nesting level.
func headingOutlineLevel(tag string) (int, bool) {
switch tag {
diff --git a/pkg/html/translate/paragraph.go b/pkg/html/translate/paragraph.go
index c1758244..a0f4bba6 100644
--- a/pkg/html/translate/paragraph.go
+++ b/pkg/html/translate/paragraph.go
@@ -1,6 +1,7 @@
package translate
import (
+ "context"
"strings"
"github.com/avdoseferovic/paper/pkg/components/col"
@@ -46,6 +47,48 @@ func (tr *translator) paragraphRowStyled(n *dom.Node, style *css.ComputedStyle)
return r
}
+// paragraphBlockRows returns the block-level rows for a paragraph. When the
+// paragraph contains form controls, its vertical padding moves out into
+// spacer rows: control chrome (background/border boxes) paints beyond the
+// text line box, so padding kept inside the richtext would be overdrawn.
+func (tr *translator) paragraphBlockRows(n *dom.Node, style *css.ComputedStyle) []core.Row {
+ if style == nil || style.PaddingTop <= 0 && style.PaddingBottom <= 0 || !containsFormControl(n) {
+ return []core.Row{tr.paragraphRowStyled(n, style)}
+ }
+ inner := cloneComputedStyle(style)
+ top, bottom := inner.PaddingTop, inner.PaddingBottom
+ inner.PaddingTop, inner.PaddingBottom = 0, 0
+ rows := []core.Row{tr.paragraphRowStyled(n, inner)}
+ if top > 0 {
+ rows = append([]core.Row{spacerRow(top)}, rows...)
+ }
+ if bottom > 0 {
+ rows = append(rows, spacerRow(bottom))
+ }
+ return rows
+}
+
+// containsFormControl reports whether any descendant is a form control tag.
+func containsFormControl(n *dom.Node) bool {
+ for _, c := range n.Children() {
+ switch c.Tag() {
+ case tagInput, tagButton, tagSelect, tagTextArea:
+ return true
+ }
+ if containsFormControl(c) {
+ return true
+ }
+ }
+ return false
+}
+
+// paragraphRowStyledContext is paragraphRowStyled for context-aware call
+// paths. Run assembly is synchronous, so ctx is not consulted beyond the
+// caller's own cancellation checks.
+func (tr *translator) paragraphRowStyledContext(_ context.Context, n *dom.Node, style *css.ComputedStyle) core.Row {
+ return tr.paragraphRowStyled(n, style)
+}
+
func richTextPropsFromStyle(style *css.ComputedStyle) props.RichText {
if style == nil {
return props.RichText{}
@@ -69,7 +112,7 @@ func richTextAlignFromCSS(value string) consts.Align {
switch strings.ToLower(strings.TrimSpace(value)) {
case flexAlignCenter:
return consts.AlignCenter
- case "right", "end":
+ case cssValueRight, "end":
return consts.AlignRight
case "justify":
return consts.AlignJustify
diff --git a/pkg/html/translate/position.go b/pkg/html/translate/position.go
new file mode 100644
index 00000000..82fd372f
--- /dev/null
+++ b/pkg/html/translate/position.go
@@ -0,0 +1,308 @@
+package translate
+
+import (
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/html/css"
+ "github.com/avdoseferovic/paper/pkg/props"
+ "github.com/avdoseferovic/paper/pkg/tree/node"
+)
+
+type offsetRow struct {
+ child core.Row
+ offsetX float64
+ offsetY float64
+}
+
+type absoluteRow struct {
+ child core.Row
+ x float64
+ y float64
+ right float64
+ width float64
+ rightAligned bool
+ position string
+ relativeToCell bool
+ zIndex int
+ zIndexSet bool
+}
+
+func (r *offsetRow) SetConfig(config *entity.Config) {
+ if r.child != nil {
+ r.child.SetConfig(config)
+ }
+}
+
+func (r *absoluteRow) SetConfig(config *entity.Config) {
+ if r.child != nil {
+ r.child.SetConfig(config)
+ }
+}
+
+func (r *offsetRow) GetStructure() *node.Node[core.Structure] {
+ str := core.Structure{
+ Type: "offset_row",
+ Details: map[string]any{
+ "offset_x": r.offsetX,
+ "offset_y": r.offsetY,
+ },
+ }
+ n := node.New(str)
+ if r.child != nil {
+ n.AddNext(r.child.GetStructure())
+ }
+ return n
+}
+
+func (r *absoluteRow) GetStructure() *node.Node[core.Structure] {
+ position := r.position
+ if position == "" {
+ position = positionAbsolute
+ }
+ details := map[string]any{
+ "position": position,
+ "x": r.x,
+ "y": r.y,
+ cssValueRight: r.right,
+ "width": r.width,
+ "right_aligned": r.rightAligned,
+ }
+ if r.relativeToCell {
+ details["relative_to_cell"] = true
+ }
+ if r.zIndexSet {
+ details["z_index"] = r.zIndex
+ }
+ str := core.Structure{
+ Type: "absolute_row",
+ Details: details,
+ }
+ n := node.New(str)
+ if r.child != nil {
+ n.AddNext(r.child.GetStructure())
+ }
+ return n
+}
+
+func (r *offsetRow) GetHeight(provider core.Provider, cell *entity.Cell) float64 {
+ if r.child == nil {
+ return 0
+ }
+ return r.child.GetHeight(provider, cell)
+}
+
+func (r *absoluteRow) GetHeight(core.Provider, *entity.Cell) float64 {
+ return 0
+}
+
+func (r *offsetRow) Render(provider core.Provider, cell entity.Cell) {
+ if r.child == nil {
+ return
+ }
+ shifted := cell.Copy()
+ shifted.X += r.offsetX
+ shifted.Y += r.offsetY
+ r.child.Render(provider, shifted)
+}
+
+func (r *absoluteRow) Render(provider core.Provider, cell entity.Cell) {
+ if r.child == nil {
+ return
+ }
+ target := cell.Copy()
+ target.X = r.absoluteX(cell)
+ if r.rightAligned {
+ target.X = r.rightAlignedX(cell)
+ }
+ target.Y = r.absoluteY(cell)
+ if r.width > 0 {
+ target.Width = r.width
+ } else {
+ target.Width = r.remainingWidth(cell, target.X)
+ }
+ target.Height = r.child.GetHeight(provider, &target)
+ r.child.Render(provider, target)
+ if pp, ok := provider.(core.PositionProvider); ok {
+ pp.SetCursor(cell.X, cell.Y)
+ }
+}
+
+func (r *offsetRow) Add(cols ...core.Col) core.Row {
+ if r.child != nil {
+ r.child = r.child.Add(cols...)
+ }
+ return r
+}
+
+func (r *absoluteRow) Add(cols ...core.Col) core.Row {
+ if r.child != nil {
+ r.child = r.child.Add(cols...)
+ }
+ return r
+}
+
+func (r *offsetRow) WithStyle(style *props.Cell) core.Row {
+ if r.child != nil {
+ r.child = r.child.WithStyle(style)
+ }
+ return r
+}
+
+func (r *absoluteRow) WithStyle(style *props.Cell) core.Row {
+ if r.child != nil {
+ r.child = r.child.WithStyle(style)
+ }
+ return r
+}
+
+func (r *offsetRow) GetColumns() []core.Col {
+ if r.child == nil {
+ return nil
+ }
+ return r.child.GetColumns()
+}
+
+func (r *absoluteRow) GetColumns() []core.Col {
+ if r.child == nil {
+ return nil
+ }
+ return r.child.GetColumns()
+}
+
+func (r *offsetRow) SplitAt(provider core.Provider, remainingHeight float64, width float64) (core.Row, core.Row, bool) {
+ splittable, ok := r.child.(core.Splittable)
+ if !ok {
+ return nil, nil, false
+ }
+ first, rest, didSplit := splittable.SplitAt(provider, remainingHeight, width)
+ if !didSplit {
+ return nil, nil, false
+ }
+ return r.wrapSplit(first), r.wrapSplit(rest), true
+}
+
+func (r *offsetRow) wrapSplit(row core.Row) core.Row {
+ if row == nil {
+ return nil
+ }
+ return &offsetRow{
+ child: row,
+ offsetX: r.offsetX,
+ offsetY: r.offsetY,
+ }
+}
+
+func (r *absoluteRow) SplitAt(core.Provider, float64, float64) (core.Row, core.Row, bool) {
+ return nil, nil, false
+}
+
+func (r *absoluteRow) IsFixedOverlay() bool {
+ return r.position == positionFixed
+}
+
+func (r *absoluteRow) RenderLayer() int {
+ if r.zIndexSet && r.zIndex < 0 {
+ return r.zIndex
+ }
+ if r.zIndexSet && r.zIndex > 0 {
+ return r.zIndex + 1
+ }
+ return 1
+}
+
+func (r *absoluteRow) absoluteX(cell entity.Cell) float64 {
+ if r.relativeToCell {
+ return cell.X + r.x
+ }
+ return r.x
+}
+
+func (r *absoluteRow) absoluteY(cell entity.Cell) float64 {
+ if r.relativeToCell {
+ return cell.Y + r.y
+ }
+ return r.y
+}
+
+func (r *absoluteRow) rightAlignedX(cell entity.Cell) float64 {
+ x := cell.Width - r.right - r.width
+ if r.relativeToCell {
+ x += cell.X
+ }
+ minX := 0.0
+ if r.relativeToCell {
+ minX = cell.X
+ }
+ if x < minX {
+ return minX
+ }
+ return x
+}
+
+func (r *absoluteRow) remainingWidth(cell entity.Cell, targetX float64) float64 {
+ startX := 0.0
+ if r.relativeToCell {
+ startX = cell.X
+ }
+ used := targetX - startX
+ if used < 0 {
+ used = 0
+ }
+ width := cell.Width - used
+ if width < 0 {
+ return 0
+ }
+ return width
+}
+
+func applyRelativePosition(style *css.ComputedStyle, rows []core.Row) []core.Row {
+ offsetX, offsetY, ok := relativePositionOffset(style)
+ if !ok || len(rows) == 0 {
+ return rows
+ }
+ out := make([]core.Row, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, &offsetRow{
+ child: r,
+ offsetX: offsetX,
+ offsetY: offsetY,
+ })
+ }
+ return out
+}
+
+func relativePositionOffset(style *css.ComputedStyle) (float64, float64, bool) {
+ if style == nil || style.Position != positionRelative {
+ return 0, 0, false
+ }
+ offsetX := style.Left
+ if offsetX == 0 && style.Right != 0 {
+ offsetX = -style.Right
+ }
+ offsetY := style.Top
+ if offsetY == 0 && style.Bottom != 0 {
+ offsetY = -style.Bottom
+ }
+ return offsetX, offsetY, offsetX != 0 || offsetY != 0
+}
+
+func newAbsolutePositionRow(style *css.ComputedStyle, rows []core.Row) core.Row {
+ if len(rows) == 0 {
+ return nil
+ }
+ child := rows[0]
+ if len(rows) > 1 {
+ child = &combinedRow{rows: rows}
+ }
+ return &absoluteRow{
+ child: child,
+ x: style.Left,
+ y: style.Top,
+ right: style.Right,
+ width: style.Width,
+ rightAligned: style.Left == 0 && style.Right != 0,
+ position: style.Position,
+ zIndex: style.ZIndex,
+ zIndexSet: style.ZIndexSet,
+ }
+}
diff --git a/pkg/html/translate/remote_assets.go b/pkg/html/translate/remote_assets.go
new file mode 100644
index 00000000..6ab2a165
--- /dev/null
+++ b/pkg/html/translate/remote_assets.go
@@ -0,0 +1,123 @@
+package translate
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+const (
+ defaultRemoteAssetBytes = 32 << 20
+ defaultRemoteStylesheetBytes = 10 << 20
+)
+
+var errHTTPStatus = errors.New("html: HTTP status error")
+
+func (tr *translator) effectiveStylesheetResolver(ctx context.Context) StylesheetResolver {
+ if tr == nil {
+ return safeDefaultStylesheetResolver
+ }
+ resolver := tr.stylesheetResolver
+ if resolver == nil {
+ resolver = safeDefaultStylesheetResolver
+ }
+ if !tr.remoteAssets {
+ return resolver
+ }
+ return func(href string) ([]byte, error) {
+ if isHTTPURL(href) {
+ return tr.fetchRemoteURL(ctx, href, defaultRemoteStylesheetBytes)
+ }
+ return resolver(href)
+ }
+}
+
+func (tr *translator) fetchRemoteURL(ctx context.Context, rawURL string, maxBytes int64) ([]byte, error) {
+ if tr != nil && tr.urlPolicy != nil {
+ err := tr.urlPolicy(rawURL)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", ErrURLPolicyDenied, err)
+ }
+ }
+ if maxBytes <= 0 {
+ maxBytes = defaultRemoteAssetBytes
+ }
+ var client *http.Client
+ if tr != nil {
+ client = tr.httpClient
+ }
+ return httpGetBytes(ctx, httpClientOrDefault(client), rawURL, maxBytes)
+}
+
+func httpClientOrDefault(client *http.Client) *http.Client {
+ if client != nil {
+ return client
+ }
+ return http.DefaultClient
+}
+
+func httpGetBytes(ctx context.Context, client *http.Client, rawURL string, maxBytes int64) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("html: building request for %s: %w", rawURL, err)
+ }
+ // Remote asset fetching is explicit opt-in (WithRemoteAssets); URLPolicy
+ // lets callers gate the reachable targets.
+ resp, err := client.Do(req) //nolint:gosec,nolintlint // G704 fires only on some gosec versions; see opt-in note above.
+ if err != nil {
+ return nil, fmt.Errorf("html: fetch %s: %w", rawURL, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("html: fetch %s: %w: %s", rawURL, errHTTPStatus, resp.Status)
+ }
+ data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes))
+ if err != nil {
+ return nil, fmt.Errorf("html: reading %s: %w", rawURL, err)
+ }
+ return data, nil
+}
+
+func isHTTPURL(value string) bool {
+ u, err := url.Parse(strings.TrimSpace(value))
+ if err != nil || u.Host == "" {
+ return false
+ }
+ return u.Scheme == "http" || u.Scheme == "https"
+}
+
+func extFromURL(rawURL string) string {
+ u, err := url.Parse(rawURL)
+ if err == nil && u.Path != "" {
+ return extFromFilename(u.Path)
+ }
+ return extFromFilename(rawURL)
+}
+
+// installRemoteImageResolver routes http(s)
sources through the remote
+// fetcher when remote assets are enabled and no custom resolver is set. The
+// returned resolver captures ctx so image fetches observe cancellation.
+func (tr *translator) installRemoteImageResolver(ctx context.Context) {
+ if tr == nil || !tr.remoteAssets || tr.imageResolver != nil {
+ return
+ }
+ baseDir := tr.imageBaseDir
+ limits := tr.limits
+ tr.imageResolver = func(src string) ([]byte, string, error) {
+ if isHTTPURL(src) {
+ data, err := tr.fetchRemoteURL(ctx, src, defaultRemoteAssetBytes)
+ if err != nil {
+ return nil, "", err
+ }
+ return data, extFromURL(src), nil
+ }
+ if baseDir != "" {
+ return baseDirResolverWithLimits(baseDir, limits)(src)
+ }
+ return safeDefaultResolverWithLimits(src, limits)
+ }
+}
diff --git a/pkg/html/translate/selector_expand.go b/pkg/html/translate/selector_expand.go
new file mode 100644
index 00000000..20e09aad
--- /dev/null
+++ b/pkg/html/translate/selector_expand.go
@@ -0,0 +1,245 @@
+package translate
+
+import "strings"
+
+type selectorFunctionLocation struct {
+ start int
+ argsStart int
+ argsEnd int
+ end int
+}
+
+func expandIsWhereSelectors(selector string) []string {
+ expanded := []string{strings.TrimSpace(selector)}
+ for {
+ next := make([]string, 0, len(expanded))
+ changed := false
+ for _, sel := range expanded {
+ items, didExpand := expandOneIsWhereSelector(sel)
+ next = append(next, items...)
+ if didExpand {
+ changed = true
+ }
+ }
+ expanded = next
+ if !changed {
+ return expanded
+ }
+ }
+}
+
+func expandOneIsWhereSelector(selector string) ([]string, bool) {
+ loc, ok := findIsWhereSelectorFunction(selector)
+ if !ok {
+ if selector == "" {
+ return nil, false
+ }
+ return []string{selector}, false
+ }
+ options := splitSelectorFunctionArgs(selector[loc.argsStart:loc.argsEnd])
+ if len(options) == 0 {
+ if selector == "" {
+ return nil, false
+ }
+ return []string{selector}, false
+ }
+ var out []string
+ for _, option := range options {
+ option = strings.TrimSpace(option)
+ if option == "" {
+ continue
+ }
+ nextSelector := strings.TrimSpace(selector[:loc.start] + option + selector[loc.end:])
+ if nextSelector != "" {
+ out = append(out, nextSelector)
+ }
+ }
+ if len(out) == 0 {
+ return []string{selector}, false
+ }
+ return out, true
+}
+
+func findIsWhereSelectorFunction(selector string) (selectorFunctionLocation, bool) {
+ quote := byte(0)
+ escaped := false
+ bracketDepth := 0
+ parenDepth := 0
+ for i := range len(selector) {
+ ch := selector[i]
+ if quote != 0 {
+ if escaped {
+ escaped = false
+ continue
+ }
+ if ch == '\\' {
+ escaped = true
+ continue
+ }
+ if ch == quote {
+ quote = 0
+ }
+ continue
+ }
+ switch ch {
+ case '\'', '"':
+ quote = ch
+ continue
+ case '[':
+ bracketDepth++
+ continue
+ case ']':
+ if bracketDepth > 0 {
+ bracketDepth--
+ }
+ continue
+ case '(':
+ parenDepth++
+ continue
+ case ')':
+ if parenDepth > 0 {
+ parenDepth--
+ }
+ continue
+ }
+ if bracketDepth == 0 && parenDepth == 0 {
+ if loc, ok := selectorFunctionAt(selector, i, ":is("); ok {
+ return loc, true
+ }
+ if loc, ok := selectorFunctionAt(selector, i, ":where("); ok {
+ return loc, true
+ }
+ }
+ }
+ return selectorFunctionLocation{}, false
+}
+
+func selectorFunctionAt(selector string, start int, prefix string) (selectorFunctionLocation, bool) {
+ if start+len(prefix) > len(selector) || !strings.EqualFold(selector[start:start+len(prefix)], prefix) {
+ return selectorFunctionLocation{}, false
+ }
+ open := start + len(prefix) - 1
+ closeIdx, ok := findSelectorFunctionClose(selector, open)
+ if !ok {
+ return selectorFunctionLocation{}, false
+ }
+ return selectorFunctionLocation{
+ start: start,
+ argsStart: open + 1,
+ argsEnd: closeIdx,
+ end: closeIdx + 1,
+ }, true
+}
+
+func findSelectorFunctionClose(selector string, open int) (int, bool) {
+ depth := 0
+ bracketDepth := 0
+ quote := byte(0)
+ escaped := false
+ for i := open; i < len(selector); i++ {
+ ch := selector[i]
+ if quote != 0 {
+ if escaped {
+ escaped = false
+ continue
+ }
+ if ch == '\\' {
+ escaped = true
+ continue
+ }
+ if ch == quote {
+ quote = 0
+ }
+ continue
+ }
+ switch ch {
+ case '\'', '"':
+ quote = ch
+ case '[':
+ bracketDepth++
+ case ']':
+ if bracketDepth > 0 {
+ bracketDepth--
+ }
+ case '(':
+ if bracketDepth == 0 {
+ depth++
+ }
+ case ')':
+ if bracketDepth == 0 {
+ depth--
+ if depth == 0 {
+ return i, true
+ }
+ }
+ }
+ }
+ return -1, false
+}
+
+func splitSelectorFunctionArgs(args string) []string {
+ var parts []string
+ var b strings.Builder
+ depth := 0
+ bracketDepth := 0
+ quote := byte(0)
+ escaped := false
+ flush := func() {
+ part := strings.TrimSpace(b.String())
+ if part != "" {
+ parts = append(parts, part)
+ }
+ b.Reset()
+ }
+ for i := range len(args) {
+ ch := args[i]
+ if quote != 0 {
+ b.WriteByte(ch)
+ if escaped {
+ escaped = false
+ continue
+ }
+ if ch == '\\' {
+ escaped = true
+ continue
+ }
+ if ch == quote {
+ quote = 0
+ }
+ continue
+ }
+ switch ch {
+ case '\'', '"':
+ quote = ch
+ b.WriteByte(ch)
+ case '[':
+ bracketDepth++
+ b.WriteByte(ch)
+ case ']':
+ if bracketDepth > 0 {
+ bracketDepth--
+ }
+ b.WriteByte(ch)
+ case '(':
+ if bracketDepth == 0 {
+ depth++
+ }
+ b.WriteByte(ch)
+ case ')':
+ if bracketDepth == 0 && depth > 0 {
+ depth--
+ }
+ b.WriteByte(ch)
+ case ',':
+ if depth == 0 && bracketDepth == 0 {
+ flush()
+ continue
+ }
+ b.WriteByte(ch)
+ default:
+ b.WriteByte(ch)
+ }
+ }
+ flush()
+ return parts
+}
diff --git a/pkg/html/translate/strict_assets.go b/pkg/html/translate/strict_assets.go
new file mode 100644
index 00000000..66497da0
--- /dev/null
+++ b/pkg/html/translate/strict_assets.go
@@ -0,0 +1,20 @@
+package translate
+
+import "errors"
+
+func (tr *translator) reportAssetError(category, ref string, err error) {
+ if tr == nil || !tr.strictAssets || err == nil {
+ return
+ }
+ if errors.Is(err, ErrURLPolicyDenied) {
+ return
+ }
+ tr.assetErrs = append(tr.assetErrs, NewAssetError(category, ref, err))
+}
+
+func (tr *translator) strictAssetError() error {
+ if tr == nil || len(tr.assetErrs) == 0 {
+ return nil
+ }
+ return errors.Join(tr.assetErrs...)
+}
diff --git a/pkg/html/translate/style.go b/pkg/html/translate/style.go
index 47013ced..df0f7ba7 100644
--- a/pkg/html/translate/style.go
+++ b/pkg/html/translate/style.go
@@ -2,6 +2,7 @@ package translate
import (
"maps"
+ "strconv"
"strings"
"github.com/avdoseferovic/paper/pkg/consts"
@@ -63,30 +64,32 @@ func computeNodeStyleCtx(sheet *stylesheet, n *dom.Node, parent *css.ComputedSty
maps.Copy(s.Vars, parent.Vars)
}
}
- if sheet != nil && n.RawNode() != nil {
- sheet.applyToNodeCtx(n.RawNode(), s, parent, ctxWidth)
- }
- inline := n.InlineStyle()
- if inline != "" {
- for prop, val := range parseInlineStyle(inline) {
- s.ApplyCtx(prop, val, parent, ctxWidth)
- }
- }
+ applyCascade(sheet, n, s, parent, ctxWidth)
return s
}
func computeInlineNodeStyle(sheet *stylesheet, n *dom.Node, parent *css.ComputedStyle) *css.ComputedStyle {
s := inheritInlineStyle(parent)
+ applyCascade(sheet, n, s, parent, 0)
+ return s
+}
+
+// applyCascade layers stylesheet and inline declarations in CSS cascade order:
+// stylesheet normal → inline normal → stylesheet !important → inline !important.
+func applyCascade(sheet *stylesheet, n *dom.Node, s, parent *css.ComputedStyle, ctxWidth float64) {
if sheet != nil && n.RawNode() != nil {
- sheet.applyToNodeCtx(n.RawNode(), s, parent, 0)
+ sheet.applyToNodeCtx(n.RawNode(), s, parent, ctxWidth, false)
}
- inline := n.InlineStyle()
- if inline != "" {
- for prop, val := range parseInlineStyle(inline) {
- s.Apply(prop, val, parent)
- }
+ normal, important := parseInlineStyleImportance(n.InlineStyle())
+ for prop, val := range normal {
+ s.ApplyCtx(prop, val, parent, ctxWidth)
+ }
+ if sheet != nil && n.RawNode() != nil {
+ sheet.applyToNodeCtx(n.RawNode(), s, parent, ctxWidth, true)
+ }
+ for prop, val := range important {
+ s.ApplyCtx(prop, val, parent, ctxWidth)
}
- return s
}
func computePseudoNodeStyle(sheet *stylesheet, n *dom.Node, parent *css.ComputedStyle, pseudo string) *css.ComputedStyle {
@@ -1080,22 +1083,13 @@ func readCSSContentString(value string) (string, string, bool) {
}
quote := value[0]
var out strings.Builder
- escaped := false
for i := 1; i < len(value); i++ {
ch := value[i]
- if escaped {
- switch ch {
- case 'a', 'A':
- out.WriteByte('\n')
- default:
- out.WriteByte(ch)
- }
- escaped = false
- continue
- }
switch ch {
case '\\':
- escaped = true
+ text, next := decodeCSSEscape(value, i+1)
+ out.WriteString(text)
+ i = next - 1
case quote:
return out.String(), value[i+1:], true
default:
@@ -1105,6 +1099,41 @@ func readCSSContentString(value string) (string, string, bool) {
return "", "", false
}
+// decodeCSSEscape decodes the escape sequence starting after a backslash at
+// value[start] per CSS Syntax §4.3.7: one to six hex digits followed by an
+// optional whitespace terminator, or a single literal character. It returns
+// the decoded text and the index of the first unconsumed byte.
+func decodeCSSEscape(value string, start int) (string, int) {
+ if start >= len(value) {
+ return "", start
+ }
+ end := start
+ for end < len(value) && end-start < 6 && isHexDigit(value[end]) {
+ end++
+ }
+ if end == start {
+ return string(value[start]), start + 1
+ }
+ code, err := strconv.ParseUint(value[start:end], 16, 32)
+ if err != nil || code == 0 || code > 0x10FFFF {
+ return string(value[start]), start + 1
+ }
+ // A single whitespace after the hex digits terminates the escape.
+ if end < len(value) && (value[end] == ' ' || value[end] == '\t' || value[end] == '\n') {
+ end++
+ }
+ return string(rune(code)), end
+}
+
+func isHexDigit(c byte) bool {
+ switch {
+ case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
+ return true
+ default:
+ return false
+ }
+}
+
// cssShadowToProps converts a single css.Shadow to a *props.Shadow.
func cssShadowToProps(s *css.Shadow) *props.Shadow {
if s == nil {
@@ -1135,8 +1164,20 @@ func isDisplayNone(n *dom.Node) bool {
// parseInlineStyle parses a CSS declaration block (e.g. "color:red; font-size:12pt")
// into a property→value map. Shorthands are expanded via css.ExpandShorthands.
+// !important suffixes are stripped; importance is ignored (see
+// parseInlineStyleImportance for cascade-aware parsing).
func parseInlineStyle(decl string) map[string]string {
- raw := make(map[string]string)
+ normal, important := parseInlineStyleImportance(decl)
+ maps.Copy(normal, important)
+ return normal
+}
+
+// parseInlineStyleImportance parses a CSS declaration block into two
+// property→value maps: normal declarations and !important ones. Shorthands
+// are expanded via css.ExpandShorthands.
+func parseInlineStyleImportance(decl string) (map[string]string, map[string]string) {
+ rawNormal := make(map[string]string)
+ rawImportant := make(map[string]string)
for _, part := range splitStyleDeclarations(decl) {
part = strings.TrimSpace(part)
if part == "" {
@@ -1147,13 +1188,21 @@ func parseInlineStyle(decl string) map[string]string {
continue
}
prop = strings.TrimSpace(prop)
- val = strings.TrimSpace(val)
+ val, important := stripImportantSuffix(strings.TrimSpace(val))
if prop == "" || val == "" {
continue
}
- raw[prop] = val
+ // Expand each declaration individually, in source order, so a longhand
+ // after a shorthand ("margin: 0; margin-top: 10px") deterministically
+ // overrides it. Expanding the whole map at once would iterate in
+ // random map order.
+ target := rawNormal
+ if important {
+ target = rawImportant
+ }
+ maps.Copy(target, css.ExpandShorthands(map[string]string{prop: val}))
}
- return css.ExpandShorthands(raw)
+ return rawNormal, rawImportant
}
func splitStyleDeclarations(decl string) []string {
diff --git a/pkg/html/translate/stylesheet.go b/pkg/html/translate/stylesheet.go
index f99911fa..4ebb7c0c 100644
--- a/pkg/html/translate/stylesheet.go
+++ b/pkg/html/translate/stylesheet.go
@@ -52,6 +52,8 @@ h2 { padding: 2mm 0 1mm 0 }
h3 { padding: 1mm 0 }
th { padding: 0.8mm 1mm }
ul, ol { margin-top: 2mm; margin-bottom: 1mm }
+fieldset { margin-top: 9pt; margin-bottom: 9pt }
+legend { font-weight: bold; padding-bottom: 4pt }
dt { padding-top: 1mm }
dd { padding-bottom: 1mm }
summary { padding: 1mm 0 }
@@ -117,28 +119,34 @@ func (s *stylesheet) addParsedRule(rule *cssRule, order *int, contentWidthMM flo
}
decls := expandDeclarationsInOrder(rule.declarations)
for _, sel := range rule.selectors {
- baseSelector, pseudo := splitPseudoElementSelector(sel)
- m, err := cascadia.Parse(baseSelector)
- if err != nil {
- continue
- }
- compiled := compiledRule{
- matcher: m,
- declarations: decls,
- order: *order,
- }
- *order++
- if pseudo != "" {
- s.pseudos = append(s.pseudos, compiledPseudoRule{
- compiledRule: compiled,
- pseudo: pseudo,
- })
- continue
+ for _, expanded := range expandIsWhereSelectors(sel) {
+ s.addCompiledSelector(expanded, decls, order)
}
- s.rules = append(s.rules, compiled)
}
}
+func (s *stylesheet) addCompiledSelector(sel string, decls []cssDeclaration, order *int) {
+ baseSelector, pseudo := splitPseudoElementSelector(sel)
+ m, err := cascadia.Parse(baseSelector)
+ if err != nil {
+ return
+ }
+ compiled := compiledRule{
+ matcher: m,
+ declarations: decls,
+ order: *order,
+ }
+ *order++
+ if pseudo != "" {
+ s.pseudos = append(s.pseudos, compiledPseudoRule{
+ compiledRule: compiled,
+ pseudo: pseudo,
+ })
+ return
+ }
+ s.rules = append(s.rules, compiled)
+}
+
func expandDeclarationsInOrder(declarations []cssDeclaration) []cssDeclaration {
expanded := make([]cssDeclaration, 0, len(declarations))
for _, d := range declarations {
@@ -152,7 +160,11 @@ func expandDeclarationsInOrder(declarations []cssDeclaration) []cssDeclaration {
}
sort.Strings(keys)
for _, prop := range keys {
- expanded = append(expanded, cssDeclaration{property: prop, value: parts[prop]})
+ expanded = append(expanded, cssDeclaration{
+ property: prop,
+ value: parts[prop],
+ important: d.important,
+ })
}
}
return expanded
@@ -274,6 +286,8 @@ func splitPseudoElementSelector(selector string) (string, string) {
{value: ":before", pseudo: "before"},
{value: "::after", pseudo: "after"},
{value: ":after", pseudo: "after"},
+ {value: "::marker", pseudo: "marker"},
+ {value: "::placeholder", pseudo: "placeholder"},
} {
if strings.HasSuffix(lower, suffix.value) {
base := strings.TrimSpace(trimmed[:len(trimmed)-len(suffix.value)])
@@ -289,8 +303,16 @@ func splitPseudoElementSelector(selector string) (string, string) {
// applyToNodeCtx merges all matching stylesheet declarations into the ComputedStyle
// following CSS cascade rules: lower specificity first, equal specificity by source
// order (later wins). A class selector therefore wins over a tag selector even if
-// the tag rule appears later in the stylesheet text.
-func (s *stylesheet) applyToNodeCtx(n *html.Node, style *css.ComputedStyle, parent *css.ComputedStyle, ctxWidth float64) {
+// the tag rule appears later in the stylesheet text. Only declarations whose
+// !important flag equals important are applied — callers layer the two phases
+// around inline styles (normal → inline normal → important → inline important).
+func (s *stylesheet) applyToNodeCtx(
+ n *html.Node,
+ style *css.ComputedStyle,
+ parent *css.ComputedStyle,
+ ctxWidth float64,
+ important bool,
+) {
if s == nil || len(s.rules) == 0 {
return
}
@@ -300,6 +322,18 @@ func (s *stylesheet) applyToNodeCtx(n *html.Node, style *css.ComputedStyle, pare
matching = append(matching, rule)
}
}
+ sortRulesByCascade(matching)
+ for _, rule := range matching {
+ for _, declaration := range rule.declarations {
+ if declaration.important != important {
+ continue
+ }
+ style.ApplyCtx(declaration.property, declaration.value, parent, ctxWidth)
+ }
+ }
+}
+
+func sortRulesByCascade(matching []compiledRule) {
sort.SliceStable(matching, func(i, j int) bool {
si := matching[i].matcher.Specificity()
sj := matching[j].matcher.Specificity()
@@ -311,11 +345,20 @@ func (s *stylesheet) applyToNodeCtx(n *html.Node, style *css.ComputedStyle, pare
}
return matching[i].order < matching[j].order
})
- for _, rule := range matching {
- for _, declaration := range rule.declarations {
- style.ApplyCtx(declaration.property, declaration.value, parent, ctxWidth)
+}
+
+// hasPseudoRulesFor reports whether any rule for the given pseudo-element
+// matches the node.
+func (s *stylesheet) hasPseudoRulesFor(n *html.Node, pseudo string) bool {
+ if s == nil || n == nil {
+ return false
+ }
+ for _, rule := range s.pseudos {
+ if rule.pseudo == pseudo && rule.matcher.Match(n) {
+ return true
}
}
+ return false
}
func (s *stylesheet) applyPseudoToNodeCtx(
diff --git a/pkg/html/translate/stylesheet_resolver.go b/pkg/html/translate/stylesheet_resolver.go
index 18a18f8b..c061ac4c 100644
--- a/pkg/html/translate/stylesheet_resolver.go
+++ b/pkg/html/translate/stylesheet_resolver.go
@@ -57,21 +57,31 @@ func stylesheetBaseDirResolver(dir string) StylesheetResolver {
// URI or panicking resolver never crashes the caller. Returns the bytes
// (nil on failure) and a flag indicating whether the load succeeded.
func safeLoadStylesheet(resolver StylesheetResolver, href string) ([]byte, bool) {
+ data, err := safeLoadStylesheetErr(resolver, href)
+ return data, err == nil
+}
+
+// errStylesheetResolverPanic reports that a resolver panicked while loading;
+// the panic value is attached as context.
+var errStylesheetResolverPanic = errors.New("html: stylesheet resolver panicked")
+
+// safeLoadStylesheetErr is safeLoadStylesheet with the underlying error
+// preserved so strict-asset callers can surface it (e.g. fs.ErrNotExist).
+// A panicking resolver is converted into an error.
+func safeLoadStylesheetErr(resolver StylesheetResolver, href string) ([]byte, error) {
var data []byte
- ok := false
+ var err error
func() {
defer func() {
if r := recover(); r != nil {
data = nil
- ok = false
+ err = fmt.Errorf("%w: %q: %v", errStylesheetResolverPanic, href, r)
}
}()
- d, err := resolver(href)
- if err != nil {
- return
- }
- data = d
- ok = true
+ data, err = resolver(href)
}()
- return data, ok
+ if err != nil {
+ return nil, err
+ }
+ return data, nil
}
diff --git a/pkg/html/translate/table.go b/pkg/html/translate/table.go
index 1bbac1da..0f783108 100644
--- a/pkg/html/translate/table.go
+++ b/pkg/html/translate/table.go
@@ -35,7 +35,7 @@ func (tr *translator) tableRowsWithStyle(n *dom.Node, tableStyle *css.ComputedSt
opts = append(opts, table.WithWidth(preferredWidth))
}
}
- if tableStyle.TextAlign == "center" || tableStyle.TextAlign == "right" {
+ if tableStyle.TextAlign == flexAlignCenter || tableStyle.TextAlign == cssValueRight {
opts = append(opts, table.WithAlign(tableStyle.TextAlign))
}
if len(widths) > 0 {
@@ -91,13 +91,13 @@ func (tr *translator) tableColumnWidths(n *dom.Node, tableStyle *css.ComputedSty
if width <= 0 {
width = groupWidth
}
- span := atoiOr(child.Attr("span"), 1)
+ span := atoiOrOne(child.Attr("span"))
for range span {
widths = append(widths, width)
}
}
if !groupHasCol {
- span := atoiOr(group.Attr("span"), 1)
+ span := atoiOrOne(group.Attr("span"))
for range span {
widths = append(widths, groupWidth)
}
@@ -139,18 +139,30 @@ func (tr *translator) captionRow(n *dom.Node) core.Row {
return row.New().Add(col.New().Add(rt))
}
+// buildTableMatrix assembles table rows in rendering order: header rows
+// first, then body rows (from or direct children), then footer
+// rows — regardless of the source order of the row groups (HTML permits
+//
before ).
func (tr *translator) buildTableMatrix(n *dom.Node, tableStyle *css.ComputedStyle) [][]table.Cell {
- var matrix [][]table.Cell
+ var head, body, foot [][]table.Cell
for _, child := range n.Children() {
switch child.Tag() {
- case "thead", "tbody", "tfoot":
- matrix = append(matrix, tr.collectRows(child, tableStyle)...)
+ case "thead":
+ head = append(head, tr.collectRows(child, tableStyle)...)
+ case "tfoot":
+ foot = append(foot, tr.collectRows(child, tableStyle)...)
+ case "tbody":
+ body = append(body, tr.collectRows(child, tableStyle)...)
case "tr":
if rowCells := tr.buildRow(child, tableStyle); rowCells != nil {
- matrix = append(matrix, rowCells)
+ body = append(body, rowCells)
}
}
}
+ matrix := make([][]table.Cell, 0, len(head)+len(body)+len(foot))
+ matrix = append(matrix, head...)
+ matrix = append(matrix, body...)
+ matrix = append(matrix, foot...)
return matrix
}
@@ -183,8 +195,8 @@ func (tr *translator) buildRow(trNode *dom.Node, parentStyle *css.ComputedStyle)
// buildCell builds a single table.Cell, using rowStyle as a background/color fallback.
func (tr *translator) buildCell(td *dom.Node, rowStyle *css.ComputedStyle) table.Cell {
- colspan := atoiOr(td.Attr("colspan"), 1)
- rowspan := atoiOr(td.Attr("rowspan"), 1)
+ colspan := atoiOrOne(td.Attr("colspan"))
+ rowspan := atoiOrOne(td.Attr("rowspan"))
cellStyle := computeNodeStyle(tr.sheet, td, rowStyle)
@@ -292,7 +304,8 @@ func isEmptyTableCellStyle(cell *props.Cell) bool {
cell.BorderRadiusBottomRight == 0)
}
-func atoiOr(s string, def int) int {
+func atoiOrOne(s string) int {
+ const def = 1
if s == "" {
return def
}
diff --git a/pkg/html/translate/transform.go b/pkg/html/translate/transform.go
new file mode 100644
index 00000000..1cc11d40
--- /dev/null
+++ b/pkg/html/translate/transform.go
@@ -0,0 +1,635 @@
+package translate
+
+import (
+ "math"
+ "strconv"
+ "strings"
+ "unicode"
+
+ "github.com/avdoseferovic/paper/pkg/core"
+ "github.com/avdoseferovic/paper/pkg/core/entity"
+ "github.com/avdoseferovic/paper/pkg/html/css"
+ "github.com/avdoseferovic/paper/pkg/props"
+ "github.com/avdoseferovic/paper/pkg/tree/node"
+)
+
+const (
+ transformFuncCalc = "calc"
+ transformFuncClamp = "clamp"
+ transformFuncMax = "max"
+ transformFuncMin = "min"
+ transformFuncRotate = "rotate"
+ transformFuncScale = "scale"
+ transformFuncScaleX = "scalex"
+ transformFuncScaleY = "scaley"
+ transformFuncTranslate = "translate"
+ transformFuncTranslateX = "translatex"
+ transformFuncTranslateY = "translatey"
+ transformFuncSkew = "skew"
+ transformFuncSkewX = "skewx"
+ transformFuncSkewY = "skewy"
+
+ transformOriginCenter = "center"
+ transformOriginDefault = transformOriginCenter + " " + transformOriginCenter
+)
+
+type transformRow struct {
+ child core.Row
+ transform string
+ origin string
+ fontSize float64
+}
+
+func (r *transformRow) SetConfig(config *entity.Config) {
+ if r.child != nil {
+ r.child.SetConfig(config)
+ }
+}
+
+func (r *transformRow) GetStructure() *node.Node[core.Structure] {
+ ops := parseTransform(r.transform, r.fontSize, 0, 0)
+ str := core.Structure{
+ Type: "transform_row",
+ Details: map[string]any{
+ "transform": r.transform,
+ "origin": transformOriginValue(r.origin),
+ "operations": len(ops),
+ },
+ }
+ n := node.New(str)
+ if r.child != nil {
+ n.AddNext(r.child.GetStructure())
+ }
+ return n
+}
+
+func (r *transformRow) GetHeight(provider core.Provider, cell *entity.Cell) float64 {
+ if r.child == nil {
+ return 0
+ }
+ return r.child.GetHeight(provider, cell)
+}
+
+func (r *transformRow) Render(provider core.Provider, cell entity.Cell) {
+ if r.child == nil {
+ return
+ }
+ ops := parseTransform(r.transform, r.fontSize, cell.Width, cell.Height)
+ transformProvider, ok := provider.(core.TransformProvider)
+ if !ok || len(ops) == 0 {
+ r.child.Render(provider, cell)
+ return
+ }
+ box := cell.Copy()
+ if box.Height <= 0 {
+ box.Height = r.child.GetHeight(provider, &box)
+ }
+ originX, originY := parseTransformOrigin(r.origin, box.Width, box.Height, r.fontSize)
+ transformProvider.WithTransform(&box, originX, originY, ops, func() {
+ r.child.Render(provider, cell)
+ })
+}
+
+func (r *transformRow) Add(cols ...core.Col) core.Row {
+ if r.child != nil {
+ r.child = r.child.Add(cols...)
+ }
+ return r
+}
+
+func (r *transformRow) WithStyle(style *props.Cell) core.Row {
+ if r.child != nil {
+ r.child = r.child.WithStyle(style)
+ }
+ return r
+}
+
+func (r *transformRow) GetColumns() []core.Col {
+ if r.child == nil {
+ return nil
+ }
+ return r.child.GetColumns()
+}
+
+func (r *transformRow) SplitAt(provider core.Provider, remainingHeight float64, width float64) (core.Row, core.Row, bool) {
+ splittable, ok := r.child.(core.Splittable)
+ if !ok {
+ return nil, nil, false
+ }
+ first, rest, didSplit := splittable.SplitAt(provider, remainingHeight, width)
+ if !didSplit {
+ return nil, nil, false
+ }
+ return r.wrapSplit(first), r.wrapSplit(rest), true
+}
+
+func (r *transformRow) wrapSplit(row core.Row) core.Row {
+ if row == nil {
+ return nil
+ }
+ return &transformRow{
+ child: row,
+ transform: r.transform,
+ origin: r.origin,
+ fontSize: r.fontSize,
+ }
+}
+
+func applyTransform(style *css.ComputedStyle, rows []core.Row) []core.Row {
+ if style == nil || len(rows) == 0 || len(parseTransform(style.Transform, style.FontSize, 0, 0)) == 0 {
+ return rows
+ }
+ child := rows[0]
+ if len(rows) > 1 {
+ child = &combinedRow{rows: rows}
+ }
+ return []core.Row{&transformRow{
+ child: child,
+ transform: strings.TrimSpace(style.Transform),
+ origin: style.TransformOrigin,
+ fontSize: style.FontSize,
+ }}
+}
+
+func parseTransform(value string, fontSize, width, height float64) []props.TransformOp {
+ remaining := strings.TrimSpace(strings.ToLower(value))
+ if remaining == "" || remaining == cssValueNone {
+ return nil
+ }
+
+ var ops []props.TransformOp
+ for remaining != "" {
+ open := strings.Index(remaining, "(")
+ if open < 0 {
+ break
+ }
+ name := strings.TrimSpace(remaining[:open])
+ closeIdx := matchingCloseParen(remaining, open)
+ if closeIdx < 0 {
+ break
+ }
+ args := remaining[open+1 : closeIdx]
+ remaining = strings.TrimSpace(remaining[closeIdx+1:])
+ ops = appendTransformOp(ops, name, splitTransformArgs(args), fontSize, width, height)
+ }
+ return ops
+}
+
+func appendTransformOp(
+ ops []props.TransformOp,
+ name string,
+ parts []string,
+ fontSize, width, height float64,
+) []props.TransformOp {
+ switch name {
+ case transformFuncRotate:
+ return appendRotateTransform(ops, parts)
+ case transformFuncScale, transformFuncScaleX, transformFuncScaleY:
+ return appendScaleTransform(ops, name, parts)
+ case transformFuncTranslate, transformFuncTranslateX, transformFuncTranslateY:
+ return appendTranslateTransform(ops, name, parts, fontSize, width, height)
+ case transformFuncSkew, transformFuncSkewX, transformFuncSkewY:
+ return appendSkewTransform(ops, name, parts)
+ default:
+ return ops
+ }
+}
+
+func appendRotateTransform(ops []props.TransformOp, parts []string) []props.TransformOp {
+ if len(parts) == 0 {
+ return ops
+ }
+ return append(ops, props.TransformOp{
+ Type: props.TransformRotate,
+ Values: [2]float64{parseTransformAngle(parts[0]), 0},
+ })
+}
+
+func appendScaleTransform(ops []props.TransformOp, name string, parts []string) []props.TransformOp {
+ if len(parts) == 0 {
+ return ops
+ }
+ switch name {
+ case transformFuncScale:
+ if len(parts) >= 2 {
+ return append(ops, props.TransformOp{
+ Type: props.TransformScale,
+ Values: [2]float64{parseTransformNumber(parts[0]), parseTransformNumber(parts[1])},
+ })
+ }
+ scale := parseTransformNumber(parts[0])
+ return append(ops, props.TransformOp{Type: props.TransformScale, Values: [2]float64{scale, scale}})
+ case transformFuncScaleX:
+ return append(ops, props.TransformOp{
+ Type: props.TransformScale,
+ Values: [2]float64{parseTransformNumber(parts[0]), 1},
+ })
+ default:
+ return append(ops, props.TransformOp{
+ Type: props.TransformScale,
+ Values: [2]float64{1, parseTransformNumber(parts[0])},
+ })
+ }
+}
+
+func appendTranslateTransform(
+ ops []props.TransformOp,
+ name string,
+ parts []string,
+ fontSize, width, height float64,
+) []props.TransformOp {
+ if len(parts) == 0 {
+ return ops
+ }
+ switch name {
+ case transformFuncTranslate:
+ x := parseTransformLength(parts[0], fontSize, width)
+ y := 0.0
+ if len(parts) >= 2 {
+ y = parseTransformLength(parts[1], fontSize, height)
+ }
+ return append(ops, props.TransformOp{Type: props.TransformTranslate, Values: [2]float64{x, y}})
+ case transformFuncTranslateX:
+ return append(ops, props.TransformOp{
+ Type: props.TransformTranslate,
+ Values: [2]float64{parseTransformLength(parts[0], fontSize, width), 0},
+ })
+ default:
+ return append(ops, props.TransformOp{
+ Type: props.TransformTranslate,
+ Values: [2]float64{0, parseTransformLength(parts[0], fontSize, height)},
+ })
+ }
+}
+
+func appendSkewTransform(ops []props.TransformOp, name string, parts []string) []props.TransformOp {
+ if len(parts) == 0 {
+ return ops
+ }
+ switch name {
+ case transformFuncSkew:
+ if len(parts) >= 2 {
+ return append(ops, props.TransformOp{
+ Type: props.TransformSkew,
+ Values: [2]float64{parseTransformAngle(parts[0]), parseTransformAngle(parts[1])},
+ })
+ }
+ return append(ops, props.TransformOp{
+ Type: props.TransformSkewX,
+ Values: [2]float64{parseTransformAngle(parts[0]), 0},
+ })
+ case transformFuncSkewX:
+ return append(ops, props.TransformOp{
+ Type: props.TransformSkewX,
+ Values: [2]float64{parseTransformAngle(parts[0]), 0},
+ })
+ default:
+ return append(ops, props.TransformOp{
+ Type: props.TransformSkewY,
+ Values: [2]float64{parseTransformAngle(parts[0]), 0},
+ })
+ }
+}
+
+func parseTransformOrigin(value string, width, height, fontSize float64) (float64, float64) {
+ parts := splitTransformFields(strings.ToLower(strings.TrimSpace(value)))
+ if len(parts) == 0 {
+ return width / 2, height / 2
+ }
+ x := resolveTransformOriginComponent(parts[0], width, fontSize)
+ if len(parts) == 1 {
+ return x, height / 2
+ }
+ return x, resolveTransformOriginComponent(parts[1], height, fontSize)
+}
+
+func resolveTransformOriginComponent(value string, dimension, fontSize float64) float64 {
+ switch strings.TrimSpace(value) {
+ case "left", "top":
+ return 0
+ case transformOriginCenter:
+ return dimension / 2
+ case cssValueRight, "bottom":
+ return dimension
+ default:
+ return parseTransformLength(value, fontSize, dimension)
+ }
+}
+
+func transformOriginValue(value string) string {
+ if strings.TrimSpace(value) == "" {
+ return transformOriginDefault
+ }
+ return strings.TrimSpace(value)
+}
+
+func parseTransformLength(value string, fontSize, context float64) float64 {
+ value = strings.TrimSpace(strings.ToLower(value))
+ name, args, ok := transformFunction(value)
+ if !ok {
+ return css.ParseLengthCtx(value, fontSize, context)
+ }
+ parts := splitTransformArgs(args)
+ switch name {
+ case transformFuncMin:
+ if len(parts) == 0 {
+ return 0
+ }
+ out := parseTransformLength(parts[0], fontSize, context)
+ for _, part := range parts[1:] {
+ out = math.Min(out, parseTransformLength(part, fontSize, context))
+ }
+ return out
+ case transformFuncMax:
+ if len(parts) == 0 {
+ return 0
+ }
+ out := parseTransformLength(parts[0], fontSize, context)
+ for _, part := range parts[1:] {
+ out = math.Max(out, parseTransformLength(part, fontSize, context))
+ }
+ return out
+ case transformFuncClamp:
+ if len(parts) != 3 {
+ return 0
+ }
+ lo := parseTransformLength(parts[0], fontSize, context)
+ mid := parseTransformLength(parts[1], fontSize, context)
+ hi := parseTransformLength(parts[2], fontSize, context)
+ return math.Max(lo, math.Min(mid, hi))
+ default:
+ return css.ParseLengthCtx(value, fontSize, context)
+ }
+}
+
+func parseTransformAngle(value string) float64 {
+ value = strings.TrimSpace(strings.ToLower(value))
+ name, args, ok := transformFunction(value)
+ if ok {
+ parts := splitTransformArgs(args)
+ switch name {
+ case transformFuncCalc:
+ out, _ := evalTransformCalc(args, parseTransformAngle)
+ return out
+ case transformFuncMin:
+ return minTransformValue(parts, parseTransformAngle)
+ case transformFuncMax:
+ return maxTransformValue(parts, parseTransformAngle)
+ case transformFuncClamp:
+ return clampTransformValue(parts, parseTransformAngle)
+ }
+ }
+ return parseTransformAngleLeaf(value)
+}
+
+func parseTransformNumber(value string) float64 {
+ value = strings.TrimSpace(strings.ToLower(value))
+ name, args, ok := transformFunction(value)
+ if ok {
+ parts := splitTransformArgs(args)
+ switch name {
+ case transformFuncCalc:
+ out, _ := evalTransformCalc(args, parseTransformNumber)
+ return out
+ case transformFuncMin:
+ return minTransformValue(parts, parseTransformNumber)
+ case transformFuncMax:
+ return maxTransformValue(parts, parseTransformNumber)
+ case transformFuncClamp:
+ return clampTransformValue(parts, parseTransformNumber)
+ }
+ }
+ out, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return 0
+ }
+ return out
+}
+
+func parseTransformAngleLeaf(value string) float64 {
+ switch {
+ case strings.HasSuffix(value, "turn"):
+ v, _ := strconv.ParseFloat(strings.TrimSuffix(value, "turn"), 64)
+ return v * 360
+ case strings.HasSuffix(value, "grad"):
+ v, _ := strconv.ParseFloat(strings.TrimSuffix(value, "grad"), 64)
+ return v * 0.9
+ case strings.HasSuffix(value, "rad"):
+ v, _ := strconv.ParseFloat(strings.TrimSuffix(value, "rad"), 64)
+ return v * 180 / math.Pi
+ case strings.HasSuffix(value, "deg"):
+ v, _ := strconv.ParseFloat(strings.TrimSuffix(value, "deg"), 64)
+ return v
+ default:
+ v, _ := strconv.ParseFloat(value, 64)
+ return v
+ }
+}
+
+func minTransformValue(parts []string, parse func(string) float64) float64 {
+ if len(parts) == 0 {
+ return 0
+ }
+ out := parse(parts[0])
+ for _, part := range parts[1:] {
+ out = math.Min(out, parse(part))
+ }
+ return out
+}
+
+func maxTransformValue(parts []string, parse func(string) float64) float64 {
+ if len(parts) == 0 {
+ return 0
+ }
+ out := parse(parts[0])
+ for _, part := range parts[1:] {
+ out = math.Max(out, parse(part))
+ }
+ return out
+}
+
+func clampTransformValue(parts []string, parse func(string) float64) float64 {
+ if len(parts) != 3 {
+ return 0
+ }
+ lo := parse(parts[0])
+ mid := parse(parts[1])
+ hi := parse(parts[2])
+ return math.Max(lo, math.Min(mid, hi))
+}
+
+func evalTransformCalc(value string, parseLeaf func(string) float64) (float64, bool) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return 0, false
+ }
+ if idx, op := findTopLevelOperator(value, "+-"); idx >= 0 {
+ left, ok := evalTransformCalc(value[:idx], parseLeaf)
+ if !ok {
+ return 0, false
+ }
+ right, ok := evalTransformCalc(value[idx+1:], parseLeaf)
+ if !ok {
+ return 0, false
+ }
+ if op == '+' {
+ return left + right, true
+ }
+ return left - right, true
+ }
+ if idx, op := findTopLevelOperator(value, "*/"); idx >= 0 {
+ left, ok := evalTransformCalc(value[:idx], parseLeaf)
+ if !ok {
+ return 0, false
+ }
+ right, ok := evalTransformCalc(value[idx+1:], parseLeaf)
+ if !ok || op == '/' && right == 0 {
+ return 0, false
+ }
+ if op == '*' {
+ return left * right, true
+ }
+ return left / right, true
+ }
+ if hasOuterParens(value) {
+ return evalTransformCalc(value[1:len(value)-1], parseLeaf)
+ }
+ return parseLeaf(value), true
+}
+
+func findTopLevelOperator(value, operators string) (int, byte) {
+ depth := 0
+ for i := len(value) - 1; i >= 0; i-- {
+ switch value[i] {
+ case ')':
+ depth++
+ case '(':
+ depth--
+ default:
+ if depth == 0 && strings.ContainsRune(operators, rune(value[i])) && isBinaryTransformOperator(value, i) {
+ return i, value[i]
+ }
+ }
+ }
+ return -1, 0
+}
+
+func isBinaryTransformOperator(value string, idx int) bool {
+ if idx <= 0 {
+ return false
+ }
+ if value[idx] != '+' && value[idx] != '-' {
+ return true
+ }
+ for i := idx - 1; i >= 0; i-- {
+ if unicode.IsSpace(rune(value[i])) {
+ continue
+ }
+ return !strings.ContainsRune("+-*/(", rune(value[i]))
+ }
+ return false
+}
+
+func hasOuterParens(value string) bool {
+ if !strings.HasPrefix(value, "(") || !strings.HasSuffix(value, ")") {
+ return false
+ }
+ return matchingCloseParen(value, 0) == len(value)-1
+}
+
+func transformFunction(value string) (string, string, bool) {
+ open := strings.Index(value, "(")
+ if open <= 0 || !strings.HasSuffix(value, ")") {
+ return "", "", false
+ }
+ closeIdx := matchingCloseParen(value, open)
+ if closeIdx != len(value)-1 {
+ return "", "", false
+ }
+ return strings.TrimSpace(value[:open]), value[open+1 : closeIdx], true
+}
+
+func matchingCloseParen(value string, open int) int {
+ depth := 0
+ for i := open; i < len(value); i++ {
+ switch value[i] {
+ case '(':
+ depth++
+ case ')':
+ depth--
+ if depth == 0 {
+ return i
+ }
+ }
+ }
+ return -1
+}
+
+func splitTransformArgs(value string) []string {
+ parts := splitTransformCommas(value)
+ if len(parts) <= 1 {
+ parts = splitTransformFields(value)
+ }
+ out := parts[:0]
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ out = append(out, part)
+ }
+ }
+ return out
+}
+
+func splitTransformCommas(value string) []string {
+ var out []string
+ depth := 0
+ start := 0
+ for i, r := range value {
+ switch r {
+ case '(':
+ depth++
+ case ')':
+ if depth > 0 {
+ depth--
+ }
+ case ',':
+ if depth == 0 {
+ out = append(out, strings.TrimSpace(value[start:i]))
+ start = i + len(string(r))
+ }
+ }
+ }
+ out = append(out, strings.TrimSpace(value[start:]))
+ return out
+}
+
+func splitTransformFields(value string) []string {
+ var out []string
+ var b strings.Builder
+ depth := 0
+ flush := func() {
+ token := strings.TrimSpace(b.String())
+ if token != "" {
+ out = append(out, token)
+ }
+ b.Reset()
+ }
+ for _, r := range value {
+ switch {
+ case r == '(':
+ depth++
+ b.WriteRune(r)
+ case r == ')':
+ if depth > 0 {
+ depth--
+ }
+ b.WriteRune(r)
+ case depth == 0 && unicode.IsSpace(r):
+ flush()
+ default:
+ b.WriteRune(r)
+ }
+ }
+ flush()
+ return out
+}
diff --git a/pkg/html/translate/translate.go b/pkg/html/translate/translate.go
index 0a9f5a97..63b82d34 100644
--- a/pkg/html/translate/translate.go
+++ b/pkg/html/translate/translate.go
@@ -4,7 +4,7 @@ package translate
import (
"context"
"fmt"
- "strings"
+ "net/http"
"github.com/avdoseferovic/paper/internal/htmllimits"
"github.com/avdoseferovic/paper/pkg/core"
@@ -36,16 +36,36 @@ type translator struct {
// outlineFromHeadings, when true, marks h1-h6 paragraphs with a
// props.Outline so they appear in the PDF document outline.
outlineFromHeadings bool
+
+ // fallbackFontPath is the WithFallbackFontPath source loaded on demand when
+ // the document contains text outside the WinAnsi (cp1252) repertoire.
+ // fallbackFontReady flips once the font bytes registered successfully.
+ fallbackFontPath string
+ fallbackFontReady bool
+
+ // remoteAssets enables http(s) fetching for stylesheets and fonts.
+ // urlPolicy, when set, may veto individual URLs before any fetch.
+ // httpClient overrides http.DefaultClient for remote fetches.
+ remoteAssets bool
+ urlPolicy URLPolicy
+ httpClient *http.Client
+
+ // strictAssets records document-referenced asset load failures in assetErrs
+ // so Translate can surface them (joined) alongside the partial output.
+ strictAssets bool
+ assetErrs []error
}
// Translate walks the styled DOM and emits Paper rows. It observes ctx at
-// cheap phase and recursive traversal boundaries.
+// cheap phase and recursive traversal boundaries. With WithStrictAssets the
+// partially-translated rows are returned together with the joined asset
+// errors, so callers can decide whether to use the degraded output.
func Translate(ctx context.Context, doc *dom.Document, opts ...Option) ([]core.Row, error) {
document, err := translateDocument(ctx, doc, false, opts...)
- if err != nil || document == nil {
+ if document == nil {
return nil, err
}
- return document.Rows, nil
+ return document.Rows, err
}
// TranslateDocument walks the styled DOM and returns the full Document
@@ -74,31 +94,29 @@ func translateDocument(ctx context.Context, doc *dom.Document, extractBands bool
}
err = doc.ValidateLimits(tr.limits)
if err != nil {
- return nil, err
+ return nil, limitErrorFrom(err, tr.limits)
}
err = translationCanceled(ctx)
if err != nil {
return nil, err
}
// External stylesheets load BEFORE inline {{.Title}}
`,
+ map[string]string{"Title": "Invoice"},
+ nil,
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, doc)
+ assert.True(t, bytes.HasPrefix(doc.GetBytes(), []byte("%PDF-")))
+}
+
+func TestRenderToWritesPDF(t *testing.T) {
+ var out bytes.Buffer
+
+ err := RenderTo(context.Background(), &out, `{{.}}
`, "ok", &Options{
+ Config: config.NewBuilder().WithDimensions(80, 80).Build(),
+ })
+
+ require.NoError(t, err)
+ assert.True(t, bytes.HasPrefix(out.Bytes(), []byte("%PDF-")))
+}
+
+func TestRenderFileToUsesTemplateDirectoryForAssets(t *testing.T) {
+ dir := t.TempDir()
+ writePNG(t, filepath.Join(dir, "pixel.png"))
+ templatePath := filepath.Join(dir, "invoice.html")
+ err := os.WriteFile(templatePath, []byte(`{{.Name}}
`), 0o600)
+ require.NoError(t, err)
+
+ var out bytes.Buffer
+ err = RenderFileTo(context.Background(), &out, templatePath, map[string]string{"Name": "Acme"}, nil)
+
+ require.NoError(t, err)
+ assert.True(t, bytes.HasPrefix(out.Bytes(), []byte("%PDF-")))
+ assert.True(t, bytes.Contains(out.Bytes(), []byte("/Subtype /Image")))
+}
+
+func TestRenderFileWritesPDF(t *testing.T) {
+ dir := t.TempDir()
+ templatePath := filepath.Join(dir, "simple.html")
+ outPath := filepath.Join(dir, "out.pdf")
+ err := os.WriteFile(templatePath, []byte(`{{.}}
`), 0o600)
+ require.NoError(t, err)
+
+ err = RenderFile(context.Background(), templatePath, "ok", nil, outPath)
+
+ require.NoError(t, err)
+ data, err := os.ReadFile(outPath)
+ require.NoError(t, err)
+ assert.True(t, bytes.HasPrefix(data, []byte("%PDF-")))
+}
+
+func writePNG(t *testing.T, path string) {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
+ for y := 0; y < 2; y++ {
+ for x := 0; x < 2; x++ {
+ img.SetRGBA(x, y, color.RGBA{R: 200, G: 10, B: 20, A: 255})
+ }
+ }
+ var buf bytes.Buffer
+ err := png.Encode(&buf, img)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o600))
+}
diff --git a/tagged_pdf_test.go b/tagged_pdf_test.go
new file mode 100644
index 00000000..056c55bc
--- /dev/null
+++ b/tagged_pdf_test.go
@@ -0,0 +1,84 @@
+package paper_test
+
+import (
+ "bytes"
+ "context"
+ "testing"
+
+ "github.com/avdoseferovic/paper"
+ "github.com/avdoseferovic/paper/internal/assert"
+ "github.com/avdoseferovic/paper/internal/require"
+ "github.com/avdoseferovic/paper/pkg/components/col"
+ "github.com/avdoseferovic/paper/pkg/components/text"
+ "github.com/avdoseferovic/paper/pkg/config"
+)
+
+func TestGenerate_WithTaggedPDF_ShouldEmitStructureTreeAndMarkedContent(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithTaggedPDF(true).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("Tagged content")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+ pdfBytes := pdf.GetBytes()
+
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/MarkInfo << /Marked true >>")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/StructTreeRoot")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/S /Document")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/S /P")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/StructParents 0")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/Nums [")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("/P << /MCID 0 >> BDC")))
+ assert.True(t, bytes.Contains(pdfBytes, []byte("EMC")))
+}
+
+func TestGenerate_TaggedPDFDisabledByDefault(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().WithCompression(false).Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("Untagged content")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+ pdfBytes := pdf.GetBytes()
+
+ assert.False(t, bytes.Contains(pdfBytes, []byte("/StructTreeRoot")))
+ assert.False(t, bytes.Contains(pdfBytes, []byte("BDC")))
+}
+
+func TestGenerate_WithRuntimeSetTagged_ShouldEmitTaggedPDF(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().WithCompression(false).Build()
+ doc := paper.New(cfg)
+ doc.SetTagged(true)
+ doc.AddAutoRow(col.New(12).Add(text.New("Runtime tagged content")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+
+ assert.True(t, bytes.Contains(pdf.GetBytes(), []byte("/Marked true")))
+}
+
+func TestGenerate_WithTaggedPDFAndConcurrentMode_ShouldKeepCatalogEntries(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewBuilder().
+ WithCompression(false).
+ WithConcurrentMode(2).
+ WithTaggedPDF(true).
+ Build()
+ doc := paper.New(cfg)
+ doc.AddAutoRow(col.New(12).Add(text.New("Tagged content")))
+
+ pdf, err := doc.Generate(context.Background())
+ require.NoError(t, err)
+
+ assert.True(t, bytes.Contains(pdf.GetBytes(), []byte("/StructTreeRoot")))
+}