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(`` + "\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(), + `fallback

    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(), + `fallback

    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(), + `fallback`, + 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(`
    HEAD
    FOOT
    BODY
    `) + 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(`
    1. zero
    2. 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, `
    + Personal Info +

    Name:

    +
    `) + + 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, `
    Personal Info
    `) + + 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, `
    Login
    `) + + 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, `
    Login
    `) + + 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(`
    • 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}) + + 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(`
    • 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}) + + require.NotEmpty(t, provider.texts) + assert.Equal(t, "→ ", provider.texts[0].text) +} + +func TestTranslate_ListMarkerPseudoContentNoneSuppressesMarker(t *testing.T) { + t.Parallel() + doc, err := dom.Parse(`
    1. 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(`
    1. One
    2. 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(`
    1. Alpha
      1. Beta
    2. 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"))) +}