diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 759f67d..818636f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -63,7 +63,7 @@ body: id: gpdf-version attributes: label: gpdf Version - placeholder: "v1.0.4" + placeholder: "v1.0.5" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0d8d6..7f5ba8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.5] - 2026-04-19 + +### Fixed +- Text alignment precision for Standard 14 fonts — right/center alignment now lands at the true container edge instead of drifting up to ~17pt for large bold text + - `template/fontresolver.go`: `MeasureString` and `LineBreak` now use Adobe Core 14 AFM advance widths when no TTF is registered, instead of the `charCount × size × 0.5` approximation + - `Resolve` now normalizes an empty `FontFamily` to `Helvetica`, matching the PDF renderer's default + - Metrics (Ascender / Descender / CapHeight) from AFM are returned for Standard 14 fonts — previously hard-coded 0.8 / -0.2 / 0.7 fallback + +### Added +- `pdf/font/standard14.go` — Adobe Standard 14 font constants, `IsStandard14`, `Standard14Metrics`, `Standard14Width`, `NewStandard14Font` +- `pdf/font/standard14_data.go` — AFM-derived width tables and metrics for Helvetica / Times / Courier / Symbol / ZapfDingbats families (14 fonts, printable ASCII coverage) +- Tests: `pdf/font/standard14_test.go`, `template/fontresolver_test.go` + ## [1.0.4] - 2026-04-07 ### Added @@ -119,7 +132,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Reed-Solomon coefficient order in QR code encoder - binary.Write return value handling for errcheck lint -[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.4...HEAD +[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.5...HEAD +[1.0.5]: https://github.com/gpdf-dev/gpdf/compare/v1.0.4...v1.0.5 [1.0.4]: https://github.com/gpdf-dev/gpdf/compare/v1.0.3...v1.0.4 [1.0.3]: https://github.com/gpdf-dev/gpdf/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/gpdf-dev/gpdf/compare/v1.0.1...v1.0.2 diff --git a/_benchmark/go.mod b/_benchmark/go.mod index 1aa3a38..a74235c 100644 --- a/_benchmark/go.mod +++ b/_benchmark/go.mod @@ -7,7 +7,7 @@ replace github.com/gpdf-dev/gpdf => ../ require ( github.com/go-pdf/fpdf v1.0.4 github.com/johnfercher/maroto/v2 v2.3.3 - github.com/gpdf-dev/gpdf v1.0.4 + github.com/gpdf-dev/gpdf v1.0.5 github.com/signintech/gopdf v0.36.0 golang.org/x/image v0.18.0 ) diff --git a/_examples/component/testdata/golden/01_invoice.pdf b/_examples/component/testdata/golden/01_invoice.pdf index e3ff194..d25cbcd 100644 Binary files a/_examples/component/testdata/golden/01_invoice.pdf and b/_examples/component/testdata/golden/01_invoice.pdf differ diff --git a/_examples/component/testdata/golden/02_report.pdf b/_examples/component/testdata/golden/02_report.pdf index ed76e67..cbea42e 100644 Binary files a/_examples/component/testdata/golden/02_report.pdf and b/_examples/component/testdata/golden/02_report.pdf differ diff --git a/_examples/component/testdata/golden/03_letter.pdf b/_examples/component/testdata/golden/03_letter.pdf index 5a648ae..2967ecd 100644 Binary files a/_examples/component/testdata/golden/03_letter.pdf and b/_examples/component/testdata/golden/03_letter.pdf differ diff --git a/_examples/gotemplate/testdata/golden/00_from_template.pdf b/_examples/gotemplate/testdata/golden/00_from_template.pdf index ee2e747..a296812 100644 Binary files a/_examples/gotemplate/testdata/golden/00_from_template.pdf and b/_examples/gotemplate/testdata/golden/00_from_template.pdf differ diff --git a/_examples/gotemplate/testdata/golden/00_invoice.pdf b/_examples/gotemplate/testdata/golden/00_invoice.pdf index 3cf45d0..3fc4552 100644 Binary files a/_examples/gotemplate/testdata/golden/00_invoice.pdf and b/_examples/gotemplate/testdata/golden/00_invoice.pdf differ diff --git a/_examples/json/testdata/golden/00_overview.pdf b/_examples/json/testdata/golden/00_overview.pdf index cbbf8d6..831fafa 100644 Binary files a/_examples/json/testdata/golden/00_overview.pdf and b/_examples/json/testdata/golden/00_overview.pdf differ diff --git a/_examples/testdata/golden/02_text_styling.pdf b/_examples/testdata/golden/02_text_styling.pdf index 3cd2310..43805b6 100644 Binary files a/_examples/testdata/golden/02_text_styling.pdf and b/_examples/testdata/golden/02_text_styling.pdf differ diff --git a/_examples/testdata/golden/03_grid_layout.pdf b/_examples/testdata/golden/03_grid_layout.pdf index b92ea6f..63a1555 100644 Binary files a/_examples/testdata/golden/03_grid_layout.pdf and b/_examples/testdata/golden/03_grid_layout.pdf differ diff --git a/_examples/testdata/golden/04_fixed_height_row.pdf b/_examples/testdata/golden/04_fixed_height_row.pdf index 430b33a..7def705 100644 Binary files a/_examples/testdata/golden/04_fixed_height_row.pdf and b/_examples/testdata/golden/04_fixed_height_row.pdf differ diff --git a/_examples/testdata/golden/08_table_styled.pdf b/_examples/testdata/golden/08_table_styled.pdf index 8d484a9..2220ca1 100644 Binary files a/_examples/testdata/golden/08_table_styled.pdf and b/_examples/testdata/golden/08_table_styled.pdf differ diff --git a/_examples/testdata/golden/12_multi_page.pdf b/_examples/testdata/golden/12_multi_page.pdf index b536820..d675ed9 100644 Binary files a/_examples/testdata/golden/12_multi_page.pdf and b/_examples/testdata/golden/12_multi_page.pdf differ diff --git a/_examples/testdata/golden/13_header_footer.pdf b/_examples/testdata/golden/13_header_footer.pdf index 68d72d6..2a56f9c 100644 Binary files a/_examples/testdata/golden/13_header_footer.pdf and b/_examples/testdata/golden/13_header_footer.pdf differ diff --git a/_examples/testdata/golden/16_margins.pdf b/_examples/testdata/golden/16_margins.pdf index 276c761..b42cd91 100644 Binary files a/_examples/testdata/golden/16_margins.pdf and b/_examples/testdata/golden/16_margins.pdf differ diff --git a/_examples/testdata/golden/17_colors.pdf b/_examples/testdata/golden/17_colors.pdf index fef0eaf..cbbfa9a 100644 Binary files a/_examples/testdata/golden/17_colors.pdf and b/_examples/testdata/golden/17_colors.pdf differ diff --git a/_examples/testdata/golden/18_invoice.pdf b/_examples/testdata/golden/18_invoice.pdf index 1281f7c..cc9af4a 100644 Binary files a/_examples/testdata/golden/18_invoice.pdf and b/_examples/testdata/golden/18_invoice.pdf differ diff --git a/_examples/testdata/golden/19_report.pdf b/_examples/testdata/golden/19_report.pdf index 634f2d0..3cc0dec 100644 Binary files a/_examples/testdata/golden/19_report.pdf and b/_examples/testdata/golden/19_report.pdf differ diff --git a/_examples/testdata/golden/21_facade.pdf b/_examples/testdata/golden/21_facade.pdf index 0b6c646..372386e 100644 Binary files a/_examples/testdata/golden/21_facade.pdf and b/_examples/testdata/golden/21_facade.pdf differ diff --git a/_examples/testdata/golden/23_text_indent.pdf b/_examples/testdata/golden/23_text_indent.pdf index d7360f9..a2794af 100644 Binary files a/_examples/testdata/golden/23_text_indent.pdf and b/_examples/testdata/golden/23_text_indent.pdf differ diff --git a/_examples/testdata/golden/24_text_decoration.pdf b/_examples/testdata/golden/24_text_decoration.pdf index 328709e..4106a6b 100644 Binary files a/_examples/testdata/golden/24_text_decoration.pdf and b/_examples/testdata/golden/24_text_decoration.pdf differ diff --git a/_examples/testdata/golden/25_table_vertical_align.pdf b/_examples/testdata/golden/25_table_vertical_align.pdf index 009ee0a..c5a3e09 100644 Binary files a/_examples/testdata/golden/25_table_vertical_align.pdf and b/_examples/testdata/golden/25_table_vertical_align.pdf differ diff --git a/_examples/testdata/golden/26_page_number.pdf b/_examples/testdata/golden/26_page_number.pdf index 9fa5b04..e714895 100644 Binary files a/_examples/testdata/golden/26_page_number.pdf and b/_examples/testdata/golden/26_page_number.pdf differ diff --git a/_examples/testdata/golden/29_qr_barcode_invoice.pdf b/_examples/testdata/golden/29_qr_barcode_invoice.pdf index 0496a3f..3bb9933 100644 Binary files a/_examples/testdata/golden/29_qr_barcode_invoice.pdf and b/_examples/testdata/golden/29_qr_barcode_invoice.pdf differ diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 2d2139a..7d31087 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,4 +3,4 @@ package buildinfo // Version is the library version. It is the single source of truth used by // the public gpdf.Version constant and the default PDF Producer metadata. -const Version = "1.0.4" +const Version = "1.0.5" diff --git a/pdf/font/standard14.go b/pdf/font/standard14.go new file mode 100644 index 0000000..1b7f7ef --- /dev/null +++ b/pdf/font/standard14.go @@ -0,0 +1,106 @@ +package font + +// Adobe Standard 14 font names. PDF viewers render these without any +// embedded font data, using their own built-in metrics. gpdf writes text +// with these fonts as Type1 font entries (see pdf/writer.go:RegisterFont); +// layout measurement must match the AFM widths that viewers use so that +// right/center alignment lands at the same coordinate the PDF viewer +// actually draws the glyphs at. +const ( + Helvetica = "Helvetica" + HelveticaBold = "Helvetica-Bold" + HelveticaOblique = "Helvetica-Oblique" + HelveticaBoldOblique = "Helvetica-BoldOblique" + TimesRoman = "Times-Roman" + TimesBold = "Times-Bold" + TimesItalic = "Times-Italic" + TimesBoldItalic = "Times-BoldItalic" + Courier = "Courier" + CourierBold = "Courier-Bold" + CourierOblique = "Courier-Oblique" + CourierBoldOblique = "Courier-BoldOblique" + Symbol = "Symbol" + ZapfDingbats = "ZapfDingbats" +) + +// Standard14EmUnits is the design unit size for all Adobe 14 fonts. +const Standard14EmUnits = 1000 + +// IsStandard14 reports whether name is one of the Adobe 14 standard fonts. +func IsStandard14(name string) bool { + _, ok := standard14Data[name] + return ok +} + +// Standard14Metrics returns design-unit metrics for a Standard 14 font, +// or zero Metrics and false if the name is not a Standard 14 font. +func Standard14Metrics(name string) (Metrics, bool) { + d, ok := standard14Data[name] + if !ok { + return Metrics{}, false + } + return d.metrics, true +} + +// Standard14Width returns the advance width of r in the named font, in +// design units (1000 per em). Returns the space glyph's width when r is +// not present in the font's width table, matching the fallback most PDF +// viewers apply when asked to measure characters outside the font's +// encoding. +func Standard14Width(name string, r rune) (int, bool) { + d, ok := standard14Data[name] + if !ok { + return 0, false + } + if w, ok := d.widths[r]; ok { + return w, true + } + return d.widths[' '], true +} + +// standard14FontEntry carries the metrics and per-rune advance widths for +// one Adobe 14 font. +type standard14FontEntry struct { + metrics Metrics + widths map[rune]int +} + +// standard14Font implements Font for layout measurement of Adobe 14 fonts +// for which no TTF is registered. Encode/Subset are not meaningful for +// non-embedded Type1 fonts and return empty results. +type standard14Font struct { + name string + metrics Metrics + widths map[rune]int +} + +// NewStandard14Font returns a Font backed by the AFM width tables for the +// named Adobe 14 font. Returns (nil, false) if name is not a Standard 14 +// font. The returned Font is safe for layout measurement (Metrics and +// GlyphWidth) but Encode returns the raw UTF-8 bytes and Subset is a +// no-op — non-embedded Type1 fonts do not require subsetting. +func NewStandard14Font(name string) (Font, bool) { + d, ok := standard14Data[name] + if !ok { + return nil, false + } + return &standard14Font{name: name, metrics: d.metrics, widths: d.widths}, true +} + +func (f *standard14Font) Name() string { return f.name } +func (f *standard14Font) Metrics() Metrics { return f.metrics } + +func (f *standard14Font) GlyphWidth(r rune) (int, bool) { + if w, ok := f.widths[r]; ok { + return w, true + } + return 0, false +} + +func (f *standard14Font) Encode(text string) []byte { + return []byte(text) +} + +func (f *standard14Font) Subset(_ []rune) ([]byte, error) { + return nil, nil +} diff --git a/pdf/font/standard14_data.go b/pdf/font/standard14_data.go new file mode 100644 index 0000000..afff93a --- /dev/null +++ b/pdf/font/standard14_data.go @@ -0,0 +1,221 @@ +package font + +// Advance widths and metrics for the Adobe Core 14 fonts in 1000 em units. +// +// Values transcribed from the Adobe Font Metrics (AFM) files published by +// Adobe as part of the Core Font Program. Coverage is printable ASCII +// (0x20-0x7E); characters outside this range fall back to the space +// glyph's width — which matches how PDF viewers render unmapped runes for +// non-embedded Type1 fonts. + +// printableASCIIWidths maps rune positions 0x20..0x7E (space through ~) to +// their advance widths. Helper to expand a compact [95]uint16 into the +// rune-keyed map stored on each font entry. +func printableASCIIWidths(ws [95]uint16) map[rune]int { + m := make(map[rune]int, 95) + for i, w := range ws { + m[rune(0x20+i)] = int(w) + } + return m +} + +// monospaceWidths returns a 95-entry table where every printable ASCII +// glyph has the same advance width — used for the Courier family. +func monospaceWidths(w uint16) [95]uint16 { + var arr [95]uint16 + for i := range arr { + arr[i] = w + } + return arr +} + +var helveticaASCII = [95]uint16{ + // 0x20..0x27: ! " # $ % & ' + 278, 278, 355, 556, 556, 889, 667, 191, + // 0x28..0x2F: ( ) * + , - . / + 333, 333, 389, 584, 278, 333, 278, 278, + // 0x30..0x37: 0 1 2 3 4 5 6 7 + 556, 556, 556, 556, 556, 556, 556, 556, + // 0x38..0x3F: 8 9 : ; < = > ? + 556, 556, 278, 278, 584, 584, 584, 556, + // 0x40..0x47: @ A B C D E F G + 1015, 667, 667, 722, 722, 667, 611, 778, + // 0x48..0x4F: H I J K L M N O + 722, 278, 500, 667, 556, 833, 722, 778, + // 0x50..0x57: P Q R S T U V W + 667, 778, 722, 667, 611, 722, 667, 944, + // 0x58..0x5F: X Y Z [ \ ] ^ _ + 667, 667, 611, 278, 278, 278, 469, 556, + // 0x60..0x67: ` a b c d e f g + 333, 556, 556, 500, 556, 556, 278, 556, + // 0x68..0x6F: h i j k l m n o + 556, 222, 222, 500, 222, 833, 556, 556, + // 0x70..0x77: p q r s t u v w + 556, 556, 333, 500, 278, 556, 500, 722, + // 0x78..0x7E: x y z { | } ~ + 500, 500, 500, 334, 260, 334, 584, +} + +var helveticaBoldASCII = [95]uint16{ + 278, 333, 474, 556, 556, 889, 722, 238, + 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, + 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, + 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, + 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, + 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, + 556, 556, 500, 389, 280, 389, 584, +} + +var timesRomanASCII = [95]uint16{ + 250, 333, 408, 500, 500, 833, 778, 180, + 333, 333, 500, 564, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 278, 278, 564, 564, 564, 444, + 921, 722, 667, 667, 722, 611, 556, 722, + 722, 333, 389, 722, 611, 889, 722, 722, + 556, 722, 667, 556, 611, 722, 722, 944, + 722, 722, 611, 333, 278, 333, 469, 500, + 333, 444, 500, 444, 500, 444, 333, 500, + 500, 278, 278, 500, 278, 778, 500, 500, + 500, 500, 333, 389, 278, 500, 500, 722, + 500, 500, 444, 480, 200, 480, 541, +} + +var timesBoldASCII = [95]uint16{ + 250, 333, 555, 500, 500, 1000, 833, 278, + 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 333, 333, 570, 570, 570, 500, + 930, 722, 667, 722, 722, 667, 611, 778, + 778, 389, 500, 778, 667, 944, 722, 778, + 611, 778, 722, 556, 667, 722, 722, 1000, + 722, 722, 667, 333, 278, 333, 581, 500, + 333, 500, 556, 444, 556, 444, 333, 500, + 556, 278, 333, 556, 278, 833, 556, 500, + 556, 556, 444, 389, 333, 556, 500, 722, + 500, 500, 444, 394, 220, 394, 520, +} + +var timesItalicASCII = [95]uint16{ + 250, 333, 420, 500, 500, 833, 778, 214, + 333, 333, 500, 675, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 333, 333, 675, 675, 675, 500, + 920, 611, 611, 667, 722, 611, 611, 722, + 722, 333, 444, 667, 556, 833, 667, 722, + 611, 722, 611, 500, 556, 722, 611, 833, + 611, 556, 556, 389, 278, 389, 422, 500, + 333, 500, 500, 444, 500, 444, 278, 500, + 500, 278, 278, 444, 278, 722, 500, 500, + 500, 500, 389, 389, 278, 500, 444, 667, + 444, 444, 389, 400, 275, 400, 541, +} + +var timesBoldItalicASCII = [95]uint16{ + 250, 389, 555, 500, 500, 833, 778, 278, + 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 333, 333, 570, 570, 570, 500, + 832, 667, 667, 667, 722, 667, 667, 722, + 778, 389, 500, 667, 611, 889, 722, 722, + 611, 722, 667, 556, 611, 722, 667, 889, + 667, 611, 611, 333, 278, 333, 570, 500, + 333, 500, 500, 444, 500, 444, 333, 500, + 556, 278, 278, 500, 278, 778, 556, 500, + 500, 500, 389, 389, 278, 556, 444, 667, + 500, 444, 389, 348, 220, 348, 570, +} + +// Symbol and ZapfDingbats use specialized encodings that don't map to +// printable ASCII in the usual way. For layout measurement we return a +// plausible average width — users generally don't set body text in these +// fonts, and any mismatch only affects alignment within those contexts. +var symbolFallbackWidth = uint16(500) +var zapfDingbatsFallbackWidth = uint16(788) + +// standard14Data holds per-font metrics and ASCII width tables. +// Helvetica-Oblique / Helvetica-BoldOblique share widths with their upright +// counterparts (obliquing does not alter advance widths). Courier-* are all +// 600 em units wide (monospace). +var standard14Data = map[string]standard14FontEntry{ + Helvetica: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 718, Descender: -207, CapHeight: 718, XHeight: 523, LineGap: 0}, + widths: printableASCIIWidths(helveticaASCII), + }, + HelveticaBold: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 718, Descender: -207, CapHeight: 718, XHeight: 532, LineGap: 0}, + widths: printableASCIIWidths(helveticaBoldASCII), + }, + HelveticaOblique: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 718, Descender: -207, CapHeight: 718, XHeight: 523, LineGap: 0, ItalicAngle: -12}, + widths: printableASCIIWidths(helveticaASCII), + }, + HelveticaBoldOblique: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 718, Descender: -207, CapHeight: 718, XHeight: 532, LineGap: 0, ItalicAngle: -12}, + widths: printableASCIIWidths(helveticaBoldASCII), + }, + TimesRoman: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 683, Descender: -217, CapHeight: 662, XHeight: 450, LineGap: 0}, + widths: printableASCIIWidths(timesRomanASCII), + }, + TimesBold: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 683, Descender: -217, CapHeight: 676, XHeight: 461, LineGap: 0}, + widths: printableASCIIWidths(timesBoldASCII), + }, + TimesItalic: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 683, Descender: -217, CapHeight: 653, XHeight: 441, LineGap: 0, ItalicAngle: -15.5}, + widths: printableASCIIWidths(timesItalicASCII), + }, + TimesBoldItalic: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 683, Descender: -217, CapHeight: 669, XHeight: 462, LineGap: 0, ItalicAngle: -15}, + widths: printableASCIIWidths(timesBoldItalicASCII), + }, + Courier: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 629, Descender: -157, CapHeight: 562, XHeight: 426, LineGap: 0}, + widths: printableASCIIWidths(monospaceWidths(600)), + }, + CourierBold: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 629, Descender: -157, CapHeight: 562, XHeight: 439, LineGap: 0}, + widths: printableASCIIWidths(monospaceWidths(600)), + }, + CourierOblique: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 629, Descender: -157, CapHeight: 562, XHeight: 426, LineGap: 0, ItalicAngle: -12}, + widths: printableASCIIWidths(monospaceWidths(600)), + }, + CourierBoldOblique: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 629, Descender: -157, CapHeight: 562, XHeight: 439, LineGap: 0, ItalicAngle: -12}, + widths: printableASCIIWidths(monospaceWidths(600)), + }, + Symbol: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 1010, Descender: -293, CapHeight: 729, XHeight: 525, LineGap: 0}, + widths: printableASCIIWidths(monospaceWidths(symbolFallbackWidth)), + }, + ZapfDingbats: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 820, Descender: -143, CapHeight: 820, XHeight: 456, LineGap: 0}, + widths: printableASCIIWidths(monospaceWidths(zapfDingbatsFallbackWidth)), + }, +} + +// Variant aliases: template.buildFontVariantID emits generic "-Italic" +// suffixes regardless of family, but the Adobe Core 14 canonical names use +// "-Oblique" for Helvetica/Courier (and "-Roman" as the base form of +// Times). Map those variants so layout lookups find the same metrics the +// PDF renderer writes out via base14Variants in pdftarget.go. +func init() { + for alias, canonical := range map[string]string{ + "Helvetica-Italic": HelveticaOblique, + "Helvetica-BoldItalic": HelveticaBoldOblique, + "Courier-Italic": CourierOblique, + "Courier-BoldItalic": CourierBoldOblique, + "Times": TimesRoman, + } { + if _, exists := standard14Data[alias]; !exists { + standard14Data[alias] = standard14Data[canonical] + } + } +} diff --git a/pdf/font/standard14_test.go b/pdf/font/standard14_test.go new file mode 100644 index 0000000..30b72d2 --- /dev/null +++ b/pdf/font/standard14_test.go @@ -0,0 +1,140 @@ +package font + +import "testing" + +// Widths are checked against values published in the Adobe Core 14 AFM +// files. Spot-checking across letters with distinct widths (narrow 'I', +// wide 'M', digits, punctuation) verifies the table transcription. +func TestStandard14Width_Helvetica(t *testing.T) { + cases := []struct { + r rune + want int + }{ + {' ', 278}, {'I', 278}, {'M', 833}, {'W', 944}, + {'i', 222}, {'m', 833}, {'w', 722}, + {'0', 556}, {'1', 556}, {':', 278}, {',', 278}, {'-', 333}, + } + for _, c := range cases { + got, ok := Standard14Width(Helvetica, c.r) + if !ok { + t.Fatalf("Standard14Width(Helvetica, %q) not found", c.r) + } + if got != c.want { + t.Errorf("Helvetica %q = %d, want %d", c.r, got, c.want) + } + } +} + +func TestStandard14Width_HelveticaBold(t *testing.T) { + // Spot-check individual glyph widths against Adobe AFM values. + for _, c := range []struct { + r rune + want int + }{ + {'I', 278}, {'N', 722}, {'V', 667}, {'O', 778}, {'C', 722}, {'E', 667}, + } { + got, _ := Standard14Width(HelveticaBold, c.r) + if got != c.want { + t.Errorf("HelveticaBold %q = %d, want %d", c.r, got, c.want) + } + } + // Sum for all 7 glyphs in "INVOICE" must equal 4112 em units — + // at 28pt that is 115.136pt, matching pdftotext measurement of + // the INVOICE glyph run in golden 18_invoice.pdf. + total := 0 + for _, r := range "INVOICE" { + w, _ := Standard14Width(HelveticaBold, r) + total += w + } + if total != 4112 { + t.Errorf("sum of INVOICE glyphs = %d em units, want 4112", total) + } + wantAt28 := float64(total) * 28 / 1000 + if wantAt28 < 115.13 || wantAt28 > 115.14 { + t.Errorf("INVOICE@28pt HelveticaBold = %.3f pt, want 115.136", wantAt28) + } +} + +func TestStandard14Width_CourierMonospace(t *testing.T) { + // Every printable ASCII glyph must be 600 em units in Courier. + for r := rune(0x20); r <= 0x7E; r++ { + got, ok := Standard14Width(Courier, r) + if !ok { + t.Fatalf("Courier width missing for %q", r) + } + if got != 600 { + t.Errorf("Courier %q = %d, want 600", r, got) + } + } +} + +func TestStandard14Width_UnknownFontReturnsFalse(t *testing.T) { + if _, ok := Standard14Width("NotAFont", 'A'); ok { + t.Error("Standard14Width should return false for unknown font") + } +} + +func TestStandard14Width_UnmappedRuneFallsBackToSpace(t *testing.T) { + want, _ := Standard14Width(Helvetica, ' ') + got, ok := Standard14Width(Helvetica, '\u3042') // Japanese あ — outside ASCII + if !ok { + t.Fatal("expected fallback ok=true for unmapped rune") + } + if got != want { + t.Errorf("unmapped rune width = %d, want space width %d", got, want) + } +} + +func TestIsStandard14(t *testing.T) { + for _, name := range []string{ + Helvetica, HelveticaBold, HelveticaOblique, HelveticaBoldOblique, + TimesRoman, TimesBold, TimesItalic, TimesBoldItalic, + Courier, CourierBold, CourierOblique, CourierBoldOblique, + Symbol, ZapfDingbats, + } { + if !IsStandard14(name) { + t.Errorf("IsStandard14(%q) = false, want true", name) + } + } + if IsStandard14("RandomFont") { + t.Error("IsStandard14(\"RandomFont\") should be false") + } +} + +func TestStandard14Metrics_Helvetica(t *testing.T) { + m, ok := Standard14Metrics(Helvetica) + if !ok { + t.Fatal("Helvetica metrics missing") + } + if m.UnitsPerEm != 1000 || m.Ascender != 718 || m.Descender != -207 { + t.Errorf("Helvetica metrics %+v mismatch", m) + } +} + +func TestNewStandard14Font_MeasureStringMatchesAFM(t *testing.T) { + // Exact values from pdftotext -bbox-layout applied to a PDF rendered + // by a conformant viewer against Helvetica's AFM widths. If these drift, + // right-alignment will no longer match visible text extents. + cases := []struct { + fontName string + text string + size float64 + want float64 + }{ + {HelveticaBold, "INVOICE", 28, 115.136}, + {Helvetica, "Date: March 1, 2026", 12, 108.72}, + {Helvetica, "Due: March 31, 2026", 12, 112.056}, + } + for _, c := range cases { + f, ok := NewStandard14Font(c.fontName) + if !ok { + t.Fatalf("NewStandard14Font(%q) missing", c.fontName) + } + got := MeasureString(f, c.text, c.size) + // Allow 0.01pt tolerance for floating-point arithmetic. + if got < c.want-0.01 || got > c.want+0.01 { + t.Errorf("MeasureString(%s, %q, %v) = %.3f, want %.3f", + c.fontName, c.text, c.size, got, c.want) + } + } +} diff --git a/pdf/writer.go b/pdf/writer.go index 25128c5..e41a62b 100644 --- a/pdf/writer.go +++ b/pdf/writer.go @@ -30,7 +30,7 @@ type Writer struct { compress bool closed bool - // Extension hooks for gpdf-pro features (PDF/A, encryption, signatures). + // Extension hooks for same-module features (PDF/A, encryption, signatures). catalogExtra Dict // extra entries merged into catalog dict trailerExtra Dict // extra entries merged into trailer dict onWriteObject func(ref ObjectRef, obj Object) Object // object transformation hook diff --git a/template/builder.go b/template/builder.go index 0d2c746..3f91489 100644 --- a/template/builder.go +++ b/template/builder.go @@ -72,8 +72,9 @@ func WithMetadata(info document.DocumentMetadata) Option { } // WithWriterSetup registers a function that configures the PDF Writer -// before rendering begins. This is used by extensions (e.g., gpdf-pro) -// to set up hooks for PDF/A, encryption, or other features. +// before rendering begins. This is used by built-in extensions +// (PDF/A, encryption) and any external hook that needs to mutate +// the Writer before rendering begins. func WithWriterSetup(fn func(pw *pdf.Writer)) Option { return func(c *Config) { c.WriterSetup = fn } } diff --git a/template/fontresolver.go b/template/fontresolver.go index 2f486f4..fc6a864 100644 --- a/template/fontresolver.go +++ b/template/fontresolver.go @@ -25,6 +25,12 @@ func newBuiltinFontResolver(fonts map[string]*font.TrueTypeFont) *builtinFontRes // falls back to the base family for metrics. func (r *builtinFontResolver) Resolve(family string, weight document.FontWeight, italic bool) layout.ResolvedFont { bold := weight >= document.WeightBold + // Empty family means "the PDF renderer default" — which is Helvetica + // (see document/render/pdftarget.go:resolvePDFFontName). Mirror that + // here so layout uses the same metrics the viewer will. + if family == "" { + family = font.Helvetica + } variantID := buildFontVariantID(family, bold, italic) // Try variant-specific font first, then fall back to base family. @@ -34,7 +40,22 @@ func (r *builtinFontResolver) Resolve(family string, weight document.FontWeight, } if !ok { - // Return approximate metrics for standard fonts. + // If the requested font is one of the Adobe 14 standard fonts, use + // its AFM-derived metrics so that layout matches what PDF viewers + // render from non-embedded Type1 entries. + if m, std := font.Standard14Metrics(variantID); std { + scale := 1.0 / float64(m.UnitsPerEm) + return layout.ResolvedFont{ + ID: variantID, + Metrics: layout.FontMetrics{ + Ascender: float64(m.Ascender) * scale, + Descender: float64(m.Descender) * scale, + LineHeight: float64(m.Ascender-m.Descender+m.LineGap) * scale, + CapHeight: float64(m.CapHeight) * scale, + }, + } + } + // Return approximate metrics for non-standard unregistered fonts. return layout.ResolvedFont{ ID: variantID, Metrics: layout.FontMetrics{ @@ -75,21 +96,26 @@ func buildFontVariantID(family string, bold, italic bool) string { // MeasureString measures the width of text at the given font size. func (r *builtinFontResolver) MeasureString(f layout.ResolvedFont, text string, size float64) float64 { - ttf, ok := r.fonts[f.ID] - if !ok { - // Fallback to approximate: average char width ≈ 0.5 * fontSize. - return float64(len([]rune(text))) * size * 0.5 + if ttf, ok := r.fonts[f.ID]; ok { + return font.MeasureString(ttf, text, size) } - return font.MeasureString(ttf, text, size) + // Non-embedded Adobe 14 fonts: use AFM widths so layout matches viewer. + if std, ok := font.NewStandard14Font(f.ID); ok { + return font.MeasureString(std, text, size) + } + // Fallback to approximate: average char width ≈ 0.5 * fontSize. + return float64(len([]rune(text))) * size * 0.5 } // LineBreak splits text into lines fitting within maxWidth. func (r *builtinFontResolver) LineBreak(f layout.ResolvedFont, text string, size float64, maxWidth float64) []string { - ttf, ok := r.fonts[f.ID] - if !ok { - return approximateBreak(text, size, maxWidth) + if ttf, ok := r.fonts[f.ID]; ok { + return font.LineBreak(ttf, text, size, maxWidth) + } + if std, ok := font.NewStandard14Font(f.ID); ok { + return font.LineBreak(std, text, size, maxWidth) } - return font.LineBreak(ttf, text, size, maxWidth) + return approximateBreak(text, size, maxWidth) } // approximateBreak performs rough line breaking without font metrics. diff --git a/template/fontresolver_test.go b/template/fontresolver_test.go new file mode 100644 index 0000000..e930dc0 --- /dev/null +++ b/template/fontresolver_test.go @@ -0,0 +1,89 @@ +package template + +import ( + "math" + "testing" + + "github.com/gpdf-dev/gpdf/document" + "github.com/gpdf-dev/gpdf/pdf/font" +) + +// Without any TTF registered, MeasureString must still return AFM-accurate +// widths for Adobe 14 font names so that right/center alignment lines up +// with the glyphs PDF viewers actually draw. +func TestFontResolver_MeasureString_Standard14Fallback(t *testing.T) { + r := newBuiltinFontResolver(nil) + + cases := []struct { + family string + weight document.FontWeight + italic bool + text string + size float64 + want float64 + }{ + {"Helvetica", document.WeightBold, false, "INVOICE", 28, 115.136}, + {"Helvetica", document.WeightNormal, false, "Date: March 1, 2026", 12, 108.72}, + {"Helvetica", document.WeightNormal, false, "Due: March 31, 2026", 12, 112.056}, + } + for _, c := range cases { + f := r.Resolve(c.family, c.weight, c.italic) + got := r.MeasureString(f, c.text, c.size) + if math.Abs(got-c.want) > 0.01 { + t.Errorf("MeasureString(%s, weight=%v, %q, %v) = %.3f, want %.3f", + c.family, c.weight, c.text, c.size, got, c.want) + } + } +} + +// TTF-registered fonts continue to use the TTF measurements — Standard14 +// fallback must not override a registered font. +func TestFontResolver_MeasureString_UnknownFontFallsBackToApproximation(t *testing.T) { + r := newBuiltinFontResolver(nil) + f := r.Resolve("NonStandardFont", document.WeightNormal, false) + + got := r.MeasureString(f, "hello", 12) + want := float64(len("hello")) * 12 * 0.5 + if got != want { + t.Errorf("unknown font fallback = %.3f, want %.3f (approximation)", got, want) + } +} + +// Resolve should return AFM metrics for Standard 14 font names so line +// heights match the viewer's rendering, not the 0.8/-0.2/1.2/0.7 fallback. +func TestFontResolver_Resolve_Standard14Metrics(t *testing.T) { + r := newBuiltinFontResolver(nil) + f := r.Resolve("Helvetica", document.WeightNormal, false) + + if f.ID != font.Helvetica { + t.Fatalf("resolved font ID = %q, want %q", f.ID, font.Helvetica) + } + // Adobe Helvetica AFM: Ascender 718, Descender -207 in 1000 em units. + // Scaled to em-relative units (divide by 1000). + if math.Abs(f.Metrics.Ascender-0.718) > 0.001 { + t.Errorf("Ascender = %.4f, want 0.718", f.Metrics.Ascender) + } + if math.Abs(f.Metrics.Descender-(-0.207)) > 0.001 { + t.Errorf("Descender = %.4f, want -0.207", f.Metrics.Descender) + } +} + +// Italic Helvetica must resolve to the same metrics as Helvetica-Oblique +// (buildFontVariantID emits "-Italic"; Standard14 data aliases it). +func TestFontResolver_Resolve_HelveticaItalicAliasedToOblique(t *testing.T) { + r := newBuiltinFontResolver(nil) + f := r.Resolve("Helvetica", document.WeightNormal, true) + + // The ID reflects the generic variant name used internally, but + // metrics must come from the AFM table via the alias. + if f.Metrics.Ascender == 0.8 { + t.Error("italic Helvetica returned the 0.8 fallback Ascender instead of AFM-derived value") + } + got := r.MeasureString(f, "INVOICE", 28) + // Helvetica-Oblique shares widths with Helvetica (obliquing preserves + // advance widths): I+N+V+O+I+C+E = 278+722+667+778+278+722+667 = 4112 + // em units; at 28pt → 115.136pt. + if math.Abs(got-115.136) > 0.01 { + t.Errorf("italic Helvetica INVOICE@28 = %.3f, want 115.136 (alias miss?)", got) + } +} diff --git a/template/template_test.go b/template/template_test.go index bfd824a..8aff3c6 100644 --- a/template/template_test.go +++ b/template/template_test.go @@ -2,6 +2,7 @@ package template import ( "bytes" + "math" "strings" "testing" @@ -1523,19 +1524,23 @@ func TestApproximateBreakEmptyString(t *testing.T) { func TestBuiltinFontResolverResolveDifferentWeightsAndStyles(t *testing.T) { r := newBuiltinFontResolver(nil) - // All these should fall back since no fonts are registered. - // The returned ID should reflect the weight/style variant. + // All these families are Adobe Standard 14 — the resolver returns + // AFM-derived metrics so that layout matches how PDF viewers render + // non-embedded Type1 text. The ID carries the generic variant name + // emitted by buildFontVariantID (aliased to the canonical Adobe name + // inside the Standard14 table). tests := []struct { - family string - weight document.FontWeight - italic bool - wantID string + family string + weight document.FontWeight + italic bool + wantID string + wantAscender float64 }{ - {"Helvetica", document.WeightNormal, false, "Helvetica"}, - {"Helvetica", document.WeightBold, false, "Helvetica-Bold"}, - {"Helvetica", document.WeightNormal, true, "Helvetica-Italic"}, - {"Helvetica", document.WeightBold, true, "Helvetica-BoldItalic"}, - {"Times", document.WeightNormal, false, "Times"}, + {"Helvetica", document.WeightNormal, false, "Helvetica", 0.718}, + {"Helvetica", document.WeightBold, false, "Helvetica-Bold", 0.718}, + {"Helvetica", document.WeightNormal, true, "Helvetica-Italic", 0.718}, + {"Helvetica", document.WeightBold, true, "Helvetica-BoldItalic", 0.718}, + {"Times", document.WeightNormal, false, "Times", 0.683}, } for _, tt := range tests { @@ -1544,8 +1549,9 @@ func TestBuiltinFontResolverResolveDifferentWeightsAndStyles(t *testing.T) { t.Errorf("Resolve(%q, %v, %v) ID: got %q, want %q", tt.family, tt.weight, tt.italic, f.ID, tt.wantID) } - if f.Metrics.Ascender != 0.8 { - t.Errorf("Resolve(%q) ascender: got %v, want 0.8", tt.family, f.Metrics.Ascender) + if math.Abs(f.Metrics.Ascender-tt.wantAscender) > 0.001 { + t.Errorf("Resolve(%q) ascender: got %v, want %v", + tt.family, f.Metrics.Ascender, tt.wantAscender) } } }