diff --git a/CHANGELOG.md b/CHANGELOG.md index f597e75..a8aa0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Positioned Text Extraction** — `Page.ExtractTextElements()` and `Document.ExtractTextElementsFromPage()` return text runs with X, Y, Width, Height, FontName, FontSize (#68) - **In-Memory PDF Opening** — `OpenFromBytes()`, `OpenFromBytesWithPassword()`, and context-aware variants for server-side workflows without temp file I/O (#68) +- **Embedded Font Extraction** — `Document.GetEmbeddedFonts()` and `GetEmbeddedFontsForPage()` extract TTF/OTF font binaries from parsed PDFs for round-trip preservation (#67) +- **LoadTTFFromBytes** — `fonts.LoadTTFFromBytes()` constructor for loading fonts from extracted binary data directly into Creator API ### Changed - **Parser Reader Refactor** — Internal `Reader` refactored from `*os.File` to `io.ReadSeeker` interface, enabling in-memory PDF support diff --git a/README.md b/README.md index e1e3e20..3cc5b5f 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,19 @@ defer doc.Close() doc, _ = gxpdf.OpenFromBytesWithPassword(data, "secret") ``` +### Extracting Embedded Fonts (NEW) + +```go +doc, _ := gxpdf.Open("document.pdf") +defer doc.Close() + +// Extract all embedded fonts (TrueType, CIDFontType2) +fonts, _ := doc.GetEmbeddedFonts() +for _, f := range fonts { + fmt.Printf("Font: %s (%s), %d bytes\n", f.Name, f.Subtype, len(f.Data)) +} +``` + ### Extracting Tables from PDFs ```go diff --git a/fonts.go b/fonts.go new file mode 100644 index 0000000..09b9570 --- /dev/null +++ b/fonts.go @@ -0,0 +1,104 @@ +package gxpdf + +import ( + "fmt" + + "github.com/coregx/gxpdf/internal/extractor" +) + +// EmbeddedFont holds metadata and raw binary data for a font embedded in a PDF. +// +// The Data field contains the complete TTF/OTF binary after FlateDecode +// decompression and can be round-tripped through fonts.LoadTTFFromBytes: +// +// embFonts, err := doc.GetEmbeddedFonts() +// for _, ef := range embFonts { +// ttf, err := fonts.LoadTTFFromBytes(ef.Data) +// // ttf has the same metrics as fonts.LoadTTF would produce. +// } +type EmbeddedFont struct { + // Name is the PostScript name of the font as stored in the PDF /BaseFont entry. + Name string + + // Subtype is the PDF font subtype: "TrueType", "CIDFontType2", etc. + Subtype string + + // Data is the raw font binary (TTF/OTF bytes after FlateDecode decompression). + // Nil when the font is not embedded (Standard 14 fonts such as Helvetica). + Data []byte + + // Encoding is the PDF encoding name: "WinAnsiEncoding", "Identity-H", etc. + // Empty string when no /Encoding entry is present. + Encoding string +} + +// GetEmbeddedFonts returns all embedded fonts found across all pages in the document. +// +// Duplicate fonts that appear on multiple pages are deduplicated by Name+Subtype +// and returned only once. +// +// Fonts that are not embedded (Standard 14 fonts such as Helvetica, Times-Roman, +// Courier) are not included in the result. An empty slice (not an error) is +// returned when the document has no embedded fonts. +// +// Example: +// +// embFonts, err := doc.GetEmbeddedFonts() +// if err != nil { +// log.Fatal(err) +// } +// for _, f := range embFonts { +// fmt.Printf("Font: %s (%s), %d bytes\n", f.Name, f.Subtype, len(f.Data)) +// } +func (d *Document) GetEmbeddedFonts() ([]EmbeddedFont, error) { + fe := extractor.NewFontExtractor(d.reader) + internal, err := fe.ExtractFromDocument() + if err != nil { + return nil, fmt.Errorf("gxpdf: extract embedded fonts: %w", err) + } + return convertEmbeddedFonts(internal), nil +} + +// GetEmbeddedFontsForPage returns embedded fonts referenced on the given page (1-based). +// +// Returns an empty slice (not an error) when the page has no embedded fonts. +// Returns an error when the page number is out of range. +// +// Example: +// +// fonts, err := doc.GetEmbeddedFontsForPage(1) +// if err != nil { +// log.Fatal(err) +// } +// for _, f := range fonts { +// fmt.Printf("Page 1 font: %s (%s)\n", f.Name, f.Subtype) +// } +func (d *Document) GetEmbeddedFontsForPage(pageNum int) ([]EmbeddedFont, error) { + if pageNum < 1 || pageNum > d.PageCount() { + return nil, fmt.Errorf("gxpdf: page %d out of range (1-%d)", pageNum, d.PageCount()) + } + + fe := extractor.NewFontExtractor(d.reader) + internal, err := fe.ExtractFromPage(pageNum - 1) // convert to 0-based + if err != nil { + return nil, fmt.Errorf("gxpdf: extract embedded fonts from page %d: %w", pageNum, err) + } + return convertEmbeddedFonts(internal), nil +} + +// convertEmbeddedFonts maps the internal extractor type to the public API type. +func convertEmbeddedFonts(internal []extractor.EmbeddedFont) []EmbeddedFont { + if len(internal) == 0 { + return nil + } + result := make([]EmbeddedFont, len(internal)) + for i, ef := range internal { + result[i] = EmbeddedFont{ + Name: ef.Name, + Subtype: ef.Subtype, + Data: ef.Data, + Encoding: ef.Encoding, + } + } + return result +} diff --git a/internal/extractor/font_extractor.go b/internal/extractor/font_extractor.go new file mode 100644 index 0000000..123180e --- /dev/null +++ b/internal/extractor/font_extractor.go @@ -0,0 +1,373 @@ +package extractor + +import ( + "errors" + "fmt" + + "github.com/coregx/gxpdf/internal/parser" +) + +// ErrUnsupportedFontType is returned when a font type is not supported +// for data extraction (e.g., Type1 fonts stored in /FontFile). +// +// Callers treat this as a soft error: the font exists in the PDF but its +// binary data cannot be extracted by this implementation. GetEmbeddedFonts +// skips such fonts gracefully. +var ErrUnsupportedFontType = errors.New("extractor: unsupported font type for extraction") + +// EmbeddedFont holds raw binary data and metadata for a font embedded in a PDF. +// +// The Data field contains the complete TTF/OTF binary after FlateDecode +// decompression. It can be round-tripped via fonts.LoadTTFFromBytes: +// +// ttf, err := fonts.LoadTTFFromBytes(ef.Data) +// // ttf matches the metrics you would get from fonts.LoadTTF. +type EmbeddedFont struct { + // Name is the PostScript name of the font as stored in the PDF /BaseFont entry. + Name string + + // Subtype is the PDF font subtype: "TrueType", "CIDFontType2", etc. + Subtype string + + // Data is the raw font binary (TTF/OTF bytes after FlateDecode decompression). + // Nil when the font is not embedded (Standard 14 fonts). + Data []byte + + // Encoding is the PDF encoding name: "WinAnsiEncoding", "Identity-H", etc. + // Empty string when no /Encoding entry is present. + Encoding string +} + +// FontExtractor walks PDF page resources and extracts embedded font data. +// +// Supported font types: +// - Simple TrueType (/Subtype /TrueType) with /FontDescriptor → /FontFile2 +// - Composite CIDFontType2 (/Subtype /Type0) with /DescendantFonts → /FontDescriptor → /FontFile2 +// +// Type1 and CFF fonts are skipped gracefully (no error is propagated). +type FontExtractor struct { + reader *parser.Reader +} + +// NewFontExtractor creates a FontExtractor backed by the given PDF reader. +func NewFontExtractor(reader *parser.Reader) *FontExtractor { + return &FontExtractor{reader: reader} +} + +// ExtractFromDocument extracts embedded fonts from all pages in the document. +// +// Duplicate fonts (same Name+Subtype) that appear on multiple pages are +// deduplicated — each unique font is returned only once. +func (fe *FontExtractor) ExtractFromDocument() ([]EmbeddedFont, error) { + pageCount, err := fe.reader.GetPageCount() + if err != nil { + return nil, fmt.Errorf("font extractor: get page count: %w", err) + } + + seen := make(map[string]struct{}) + var result []EmbeddedFont + + for i := 0; i < pageCount; i++ { + pageFonts, err := fe.ExtractFromPage(i) + if err != nil { + // Skip pages that cannot be processed — not a fatal error. + continue + } + for _, ef := range pageFonts { + key := ef.Name + "\x00" + ef.Subtype + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, ef) + } + } + + return result, nil +} + +// ExtractFromPage extracts embedded fonts from the specified page (0-based). +// +// Returns an empty slice (not an error) when the page has no embedded fonts. +func (fe *FontExtractor) ExtractFromPage(pageNum int) ([]EmbeddedFont, error) { + page, err := fe.reader.GetPage(pageNum) + if err != nil { + return nil, fmt.Errorf("font extractor: get page %d: %w", pageNum, err) + } + + fontsDict, err := fe.getPageFontsDict(page) + if err != nil || fontsDict == nil { + // No font resources on this page is a valid state. + return nil, nil //nolint:nilerr + } + + var result []EmbeddedFont + for _, fontKey := range fontsDict.Keys() { + fontObj := fontsDict.Get(fontKey) + if fontObj == nil { + continue + } + + fontDict, err := fe.resolveDict(fontObj) + if err != nil || fontDict == nil { + continue + } + + ef, err := fe.extractFontData(fontDict) + if err != nil { + // Unsupported or not embedded — skip gracefully. + continue + } + if ef != nil { + result = append(result, *ef) + } + } + + return result, nil +} + +// ─── private helpers ────────────────────────────────────────────────────────── + +// getPageFontsDict returns the /Font dictionary from a page's /Resources. +// Returns (nil, nil) when the page has no fonts — not an error. +func (fe *FontExtractor) getPageFontsDict(page *parser.Dictionary) (*parser.Dictionary, error) { + resourcesObj := page.Get("Resources") + if resourcesObj == nil { + return nil, nil + } + + resourcesDict, err := fe.resolveDict(resourcesObj) + if err != nil || resourcesDict == nil { + return nil, nil //nolint:nilerr + } + + fontObj := resourcesDict.Get("Font") + if fontObj == nil { + return nil, nil + } + + return fe.resolveDict(fontObj) +} + +// extractFontData extracts the binary font data from a font dictionary. +// +// Walk order for composite (Type0) fonts: +// +// Font dict → /DescendantFonts[0] → /FontDescriptor → /FontFile2 +// +// Walk order for simple TrueType fonts: +// +// Font dict → /FontDescriptor → /FontFile2 +// +// Falls back to /FontFile and /FontFile3 in that order. +// Returns (nil, nil) for Standard-14 fonts (no embedded data). +// +//nolint:cyclop // Switch over font subtypes is inherently cyclomatic +func (fe *FontExtractor) extractFontData(fontDict *parser.Dictionary) (*EmbeddedFont, error) { + subtype := fe.nameValue(fontDict.Get("Subtype")) + fontName := fe.nameOrStringValue(fontDict.Get("BaseFont")) + encoding := fe.encodingValue(fontDict.Get("Encoding")) + + var descriptorDict *parser.Dictionary + var err error + + switch subtype { + case "Type0": + // Composite font: descriptor lives inside DescendantFonts. + descriptorDict, err = fe.descriptorFromType0(fontDict) + if err != nil || descriptorDict == nil { + return nil, nil //nolint:nilerr + } + // Report subtype as the actual CID font type. + subtype = "CIDFontType2" + + case "TrueType": + descriptorDict, err = fe.descriptorFromSimple(fontDict) + if err != nil || descriptorDict == nil { + // No descriptor → Standard-14 or unsupported; skip silently. + return nil, nil //nolint:nilerr + } + + default: + // Type1, MMType1, CIDFontType0, etc. — not supported for extraction. + return nil, ErrUnsupportedFontType + } + + data, err := fe.fontFileData(descriptorDict) + if err != nil { + return nil, err + } + if data == nil { + // Descriptor present but no /FontFile2 stream — font not embedded. + return nil, nil + } + + return &EmbeddedFont{ + Name: fontName, + Subtype: subtype, + Data: data, + Encoding: encoding, + }, nil +} + +// descriptorFromType0 resolves the FontDescriptor for a Type0 composite font. +// +// Type0 Font dict → /DescendantFonts array → [0] CIDFont dict → /FontDescriptor +func (fe *FontExtractor) descriptorFromType0(fontDict *parser.Dictionary) (*parser.Dictionary, error) { + descendantsObj := fontDict.Get("DescendantFonts") + if descendantsObj == nil { + return nil, nil + } + + arr, err := fe.resolveArray(descendantsObj) + if err != nil || arr == nil || arr.Len() == 0 { + return nil, nil //nolint:nilerr + } + + cidFontDict, err := fe.resolveDict(arr.Get(0)) + if err != nil || cidFontDict == nil { + return nil, nil //nolint:nilerr + } + + return fe.descriptorFromSimple(cidFontDict) +} + +// descriptorFromSimple resolves the FontDescriptor for a simple or CID font dict. +func (fe *FontExtractor) descriptorFromSimple(fontDict *parser.Dictionary) (*parser.Dictionary, error) { + descObj := fontDict.Get("FontDescriptor") + if descObj == nil { + return nil, nil + } + return fe.resolveDict(descObj) +} + +// fontFileData extracts and decodes the raw font bytes from a FontDescriptor. +// +// Tries /FontFile2 (TrueType), then /FontFile (Type1), then /FontFile3 (CFF). +// Returns (nil, nil) when none of the entries is present. +func (fe *FontExtractor) fontFileData(descriptor *parser.Dictionary) ([]byte, error) { + for _, key := range []string{"FontFile2", "FontFile", "FontFile3"} { + fileObj := descriptor.Get(key) + if fileObj == nil { + continue + } + + stream, err := fe.resolveStream(fileObj) + if err != nil || stream == nil { + continue + } + + data, err := decodeStreamData(stream) + if err != nil { + return nil, fmt.Errorf("decode font stream (%s): %w", key, err) + } + return data, nil + } + return nil, nil +} + +// resolveDict resolves an indirect reference and casts to *parser.Dictionary. +func (fe *FontExtractor) resolveDict(obj parser.PdfObject) (*parser.Dictionary, error) { + obj = fe.resolve(obj) + if obj == nil { + return nil, nil + } + dict, ok := obj.(*parser.Dictionary) + if !ok { + return nil, nil + } + return dict, nil +} + +// resolveArray resolves an indirect reference and casts to *parser.Array. +func (fe *FontExtractor) resolveArray(obj parser.PdfObject) (*parser.Array, error) { + obj = fe.resolve(obj) + if obj == nil { + return nil, nil + } + arr, ok := obj.(*parser.Array) + if !ok { + return nil, nil + } + return arr, nil +} + +// resolveStream resolves an indirect reference and casts to *parser.Stream. +func (fe *FontExtractor) resolveStream(obj parser.PdfObject) (*parser.Stream, error) { + obj = fe.resolve(obj) + if obj == nil { + return nil, nil + } + stream, ok := obj.(*parser.Stream) + if !ok { + return nil, nil + } + return stream, nil +} + +// resolve follows a single indirect reference using the reader's object table. +// Non-reference objects are returned as-is. Returns nil on resolution error. +func (fe *FontExtractor) resolve(obj parser.PdfObject) parser.PdfObject { + ref, ok := obj.(*parser.IndirectReference) + if !ok { + return obj + } + resolved, err := fe.reader.GetObject(ref.Number) + if err != nil { + return nil + } + return resolved +} + +// nameValue extracts the string value from a *parser.Name object. +// Returns "" for nil or non-Name objects. +func (fe *FontExtractor) nameValue(obj parser.PdfObject) string { + if obj == nil { + return "" + } + n, ok := obj.(*parser.Name) + if !ok { + return "" + } + return n.Value() +} + +// nameOrStringValue extracts a string from either a *parser.Name or *parser.String. +// Used for /BaseFont which is always a Name in spec but sometimes stored as String. +func (fe *FontExtractor) nameOrStringValue(obj parser.PdfObject) string { + if obj == nil { + return "" + } + switch v := obj.(type) { + case *parser.Name: + return v.Value() + case *parser.String: + return v.Value() + } + return "" +} + +// encodingValue extracts an encoding name from a /Encoding entry. +// The entry can be a Name or a Dictionary (with /BaseEncoding). +func (fe *FontExtractor) encodingValue(obj parser.PdfObject) string { + if obj == nil { + return "" + } + obj = fe.resolve(obj) + if obj == nil { + return "" + } + switch v := obj.(type) { + case *parser.Name: + return v.Value() + case *parser.Dictionary: + // Encoding dictionary: read BaseEncoding. + base := v.Get("BaseEncoding") + if base != nil { + if n, ok := base.(*parser.Name); ok { + return n.Value() + } + } + } + return "" +} diff --git a/internal/extractor/font_extractor_test.go b/internal/extractor/font_extractor_test.go new file mode 100644 index 0000000..ab25d3d --- /dev/null +++ b/internal/extractor/font_extractor_test.go @@ -0,0 +1,393 @@ +package extractor + +import ( + "bytes" + "compress/zlib" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/coregx/gxpdf/internal/parser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// makeFlateStream builds a *parser.Stream whose content is zlib-compressed data +// and whose dictionary has Filter=/FlateDecode. +func makeFlateStream(t *testing.T, raw []byte) *parser.Stream { + t.Helper() + compressed := zlibCompress(t, raw) + dict := parser.NewDictionary() + dict.Set("Filter", parser.NewName("FlateDecode")) + dict.Set("Length", parser.NewInteger(int64(len(compressed)))) + return parser.NewStream(dict, compressed) +} + +// makeRawStream builds a *parser.Stream with no Filter (raw content). +func makeRawStream(content []byte) *parser.Stream { + dict := parser.NewDictionary() + return parser.NewStream(dict, content) +} + +// zlibCompress is a test helper to compress bytes with zlib. +func zlibCompress(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w := zlib.NewWriter(&buf) + _, err := w.Write(data) + require.NoError(t, err) + require.NoError(t, w.Close()) + return buf.Bytes() +} + +// ─── decodeStreamData ───────────────────────────────────────────────────────── + +func TestDecodeStreamData_NoFilter(t *testing.T) { + want := []byte("hello world") + stream := makeRawStream(want) + + got, err := decodeStreamData(stream) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestDecodeStreamData_FlateDecode(t *testing.T) { + want := []byte("The quick brown fox jumps over the lazy dog") + stream := makeFlateStream(t, want) + + got, err := decodeStreamData(stream) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestDecodeStreamData_UnsupportedFilter_ReturnsRaw(t *testing.T) { + raw := []byte("compressed data") + dict := parser.NewDictionary() + dict.Set("Filter", parser.NewName("RunLengthDecode")) + stream := parser.NewStream(dict, raw) + + got, err := decodeStreamData(stream) + require.NoError(t, err) + assert.Equal(t, raw, got, "unsupported filter should return raw bytes, not error") +} + +func TestDecodeStreamData_FilterArray_FirstFilter(t *testing.T) { + want := []byte("array filter test data") + compressed := zlibCompress(t, want) + + arr := parser.NewArray() + arr.Append(parser.NewName("FlateDecode")) + dict := parser.NewDictionary() + dict.Set("Filter", arr) + stream := parser.NewStream(dict, compressed) + + got, err := decodeStreamData(stream) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestDecodeStreamData_InvalidZlib_ReturnsError(t *testing.T) { + dict := parser.NewDictionary() + dict.Set("Filter", parser.NewName("FlateDecode")) + stream := parser.NewStream(dict, []byte("not valid zlib data at all")) + + _, err := decodeStreamData(stream) + assert.Error(t, err) +} + +// ─── extractFirstFilterName ─────────────────────────────────────────────────── + +func TestExtractFirstFilterName_Name(t *testing.T) { + got := extractFirstFilterName(parser.NewName("FlateDecode")) + assert.Equal(t, "FlateDecode", got) +} + +func TestExtractFirstFilterName_NonNameObject(t *testing.T) { + got := extractFirstFilterName(parser.NewInteger(42)) + assert.Equal(t, "", got) +} + +func TestExtractFirstFilterName_EmptyArray(t *testing.T) { + got := extractFirstFilterName(parser.NewArray()) + assert.Equal(t, "", got) +} + +func TestExtractFirstFilterName_ArrayWithName(t *testing.T) { + arr := parser.NewArray() + arr.Append(parser.NewName("FlateDecode")) + arr.Append(parser.NewName("ASCII85Decode")) + got := extractFirstFilterName(arr) + assert.Equal(t, "FlateDecode", got, "should return first filter only") +} + +// ─── EmbeddedFont struct ────────────────────────────────────────────────────── + +func TestEmbeddedFont_Fields(t *testing.T) { + ef := EmbeddedFont{ + Name: "DejaVuSans", + Subtype: "TrueType", + Data: []byte{0x00, 0x01, 0x00, 0x00}, + Encoding: "WinAnsiEncoding", + } + assert.Equal(t, "DejaVuSans", ef.Name) + assert.Equal(t, "TrueType", ef.Subtype) + assert.NotEmpty(t, ef.Data) + assert.Equal(t, "WinAnsiEncoding", ef.Encoding) +} + +// ─── FontExtractor helper method unit tests ─────────────────────────────────── + +func TestFontExtractor_NameValue_Name(t *testing.T) { + fe := &FontExtractor{} + assert.Equal(t, "TrueType", fe.nameValue(parser.NewName("TrueType"))) +} + +func TestFontExtractor_NameValue_NonName(t *testing.T) { + fe := &FontExtractor{} + assert.Equal(t, "", fe.nameValue(parser.NewInteger(1))) +} + +func TestFontExtractor_NameValue_Nil(t *testing.T) { + fe := &FontExtractor{} + assert.Equal(t, "", fe.nameValue(nil)) +} + +func TestFontExtractor_NameOrStringValue_String(t *testing.T) { + fe := &FontExtractor{} + assert.Equal(t, "Arial-Bold", fe.nameOrStringValue(parser.NewString("Arial-Bold"))) +} + +func TestFontExtractor_NameOrStringValue_Name(t *testing.T) { + fe := &FontExtractor{} + assert.Equal(t, "Arial-Bold", fe.nameOrStringValue(parser.NewName("Arial-Bold"))) +} + +func TestFontExtractor_NameOrStringValue_Other(t *testing.T) { + fe := &FontExtractor{} + assert.Equal(t, "", fe.nameOrStringValue(parser.NewBoolean(true))) +} + +func TestFontExtractor_EncodingValue_Name(t *testing.T) { + fe := &FontExtractor{reader: nil} + assert.Equal(t, "WinAnsiEncoding", fe.encodingValue(parser.NewName("WinAnsiEncoding"))) +} + +func TestFontExtractor_EncodingValue_DictWithBaseEncoding(t *testing.T) { + fe := &FontExtractor{reader: nil} + dict := parser.NewDictionary() + dict.Set("BaseEncoding", parser.NewName("MacRomanEncoding")) + assert.Equal(t, "MacRomanEncoding", fe.encodingValue(dict)) +} + +func TestFontExtractor_EncodingValue_DictNoBaseEncoding(t *testing.T) { + fe := &FontExtractor{reader: nil} + dict := parser.NewDictionary() + assert.Equal(t, "", fe.encodingValue(dict)) +} + +func TestFontExtractor_EncodingValue_Nil(t *testing.T) { + fe := &FontExtractor{reader: nil} + assert.Equal(t, "", fe.encodingValue(nil)) +} + +// ─── ErrUnsupportedFontType ─────────────────────────────────────────────────── + +func TestErrUnsupportedFontType_NotNil(t *testing.T) { + assert.Error(t, ErrUnsupportedFontType) + assert.Contains(t, ErrUnsupportedFontType.Error(), "unsupported") +} + +// ─── round-trip integration test ───────────────────────────────────────────── + +// TestFontExtractor_RoundTrip_TrueType creates a minimal PDF with a /FontFile2 +// stream, writes it to a temp file, parses it, and verifies that the FontExtractor +// finds and correctly decodes the embedded font data. +func TestFontExtractor_RoundTrip_TrueType(t *testing.T) { + // Build synthetic "font data" — we are testing the extraction path, not + // that the bytes are a valid TTF. Use a recognizable sentinel. + sentinel := []byte("FAKE_TTF_BYTES_FOR_ROUNDTRIP_TEST") + pdfBytes := buildMinimalPDFWithTrueTypeFont(t, sentinel) + + // Write to temp file so parser.OpenPDF can read it. + tmpDir := t.TempDir() + pdfPath := filepath.Join(tmpDir, "roundtrip.pdf") + require.NoError(t, os.WriteFile(pdfPath, pdfBytes, 0600)) + + rd, err := parser.OpenPDF(pdfPath) + require.NoError(t, err) + defer func() { _ = rd.Close() }() + + fe := NewFontExtractor(rd) + + // Test ExtractFromPage (page 0). + pageFonts, err := fe.ExtractFromPage(0) + require.NoError(t, err) + require.Len(t, pageFonts, 1, "expected exactly 1 embedded font on page 0") + + ef := pageFonts[0] + assert.Equal(t, "FakeFont", ef.Name) + assert.Equal(t, "TrueType", ef.Subtype) + assert.Equal(t, "WinAnsiEncoding", ef.Encoding) + assert.Equal(t, sentinel, ef.Data, "extracted font data must match original") + + // Test ExtractFromDocument (deduplication). + allFonts, err := fe.ExtractFromDocument() + require.NoError(t, err) + require.Len(t, allFonts, 1, "expected exactly 1 unique embedded font in document") +} + +// TestFontExtractor_NoEmbeddedFonts_ReturnsEmpty verifies that a PDF with no +// font resources returns an empty slice, not an error. +func TestFontExtractor_NoEmbeddedFonts_ReturnsEmpty(t *testing.T) { + pdfBytes := buildMinimalPDFNoFonts(t) + + tmpDir := t.TempDir() + pdfPath := filepath.Join(tmpDir, "nofont.pdf") + require.NoError(t, os.WriteFile(pdfPath, pdfBytes, 0600)) + + rd, err := parser.OpenPDF(pdfPath) + require.NoError(t, err) + defer func() { _ = rd.Close() }() + + fe := NewFontExtractor(rd) + + pageFonts, err := fe.ExtractFromPage(0) + require.NoError(t, err) + assert.Empty(t, pageFonts, "should be empty, not error, when no fonts present") + + allFonts, err := fe.ExtractFromDocument() + require.NoError(t, err) + assert.Empty(t, allFonts) +} + +// TestFontExtractor_OutOfRangePage verifies that an invalid page number returns error. +func TestFontExtractor_OutOfRangePage(t *testing.T) { + pdfBytes := buildMinimalPDFNoFonts(t) + + tmpDir := t.TempDir() + pdfPath := filepath.Join(tmpDir, "oob.pdf") + require.NoError(t, os.WriteFile(pdfPath, pdfBytes, 0600)) + + rd, err := parser.OpenPDF(pdfPath) + require.NoError(t, err) + defer func() { _ = rd.Close() }() + + fe := NewFontExtractor(rd) + _, err = fe.ExtractFromPage(99) // page 99 doesn't exist in a 1-page PDF + assert.Error(t, err) +} + +// ─── PDF builders ───────────────────────────────────────────────────────────── + +// buildMinimalPDFWithTrueTypeFont creates a minimal valid PDF in which: +// - Page 0 has a /Font resource F1 of /Subtype /TrueType +// - F1 has a /FontDescriptor with /FontFile2 pointing to a stream +// - The stream contains zlib-compressed fontData +func buildMinimalPDFWithTrueTypeFont(t *testing.T, fontData []byte) []byte { + t.Helper() + compressed := zlibCompress(t, fontData) + return buildRawPDF(t, compressed, true) +} + +// buildMinimalPDFNoFonts creates a minimal valid 1-page PDF with no font resources. +func buildMinimalPDFNoFonts(t *testing.T) []byte { + t.Helper() + return buildRawPDF(t, nil, false) +} + +// buildRawPDF constructs a syntactically valid minimal PDF. +// If withFont is true it embeds a TrueType font with the given compressed bytes. +// Object numbering: +// +// 1 = Catalog, 2 = Pages, 3 = Page, +// 4 = Font (if withFont), 5 = FontDescriptor (if withFont), 6 = FontFile2 stream (if withFont) +// +//nolint:funlen // PDF construction is inherently verbose +func buildRawPDF(t *testing.T, compressedFont []byte, withFont bool) []byte { + t.Helper() + var b bytes.Buffer + + b.WriteString("%PDF-1.7\n") + + offsets := make([]int64, 10) // 1-based + + // Object 1: Catalog + offsets[1] = int64(b.Len()) + b.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") + + // Object 2: Pages + offsets[2] = int64(b.Len()) + b.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") + + // Object 3: Page + offsets[3] = int64(b.Len()) + if withFont { + b.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R\n") + b.WriteString(" /MediaBox [0 0 612 792]\n") + b.WriteString(" /Resources << /Font << /F1 4 0 R >> >>\n") + b.WriteString(">>\nendobj\n") + } else { + b.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R\n") + b.WriteString(" /MediaBox [0 0 612 792]\n") + b.WriteString(">>\nendobj\n") + } + + numObjs := 3 + if withFont { + // Object 4: TrueType Font dict + offsets[4] = int64(b.Len()) + b.WriteString("4 0 obj\n") + b.WriteString("<< /Type /Font /Subtype /TrueType\n") + b.WriteString(" /BaseFont /FakeFont\n") + b.WriteString(" /Encoding /WinAnsiEncoding\n") + b.WriteString(" /FontDescriptor 5 0 R\n") + b.WriteString(">>\nendobj\n") + + // Object 5: FontDescriptor + offsets[5] = int64(b.Len()) + b.WriteString("5 0 obj\n") + b.WriteString("<< /Type /FontDescriptor\n") + b.WriteString(" /FontName /FakeFont\n") + b.WriteString(" /Flags 32\n") + b.WriteString(" /FontBBox [0 -200 1000 800]\n") + b.WriteString(" /ItalicAngle 0\n") + b.WriteString(" /Ascent 800\n") + b.WriteString(" /Descent -200\n") + b.WriteString(" /CapHeight 700\n") + b.WriteString(" /StemV 80\n") + b.WriteString(" /FontFile2 6 0 R\n") + b.WriteString(">>\nendobj\n") + + // Object 6: FontFile2 stream + offsets[6] = int64(b.Len()) + b.WriteString("6 0 obj\n") + b.WriteString(fmt.Sprintf("<< /Filter /FlateDecode /Length1 %d /Length %d >>\n", + len(compressedFont), len(compressedFont))) + b.WriteString("stream\n") + b.Write(compressedFont) + b.WriteString("\nendstream\nendobj\n") + + numObjs = 6 + } + + // Cross-reference table + xrefOffset := int64(b.Len()) + b.WriteString("xref\n") + b.WriteString(fmt.Sprintf("0 %d\n", numObjs+1)) + b.WriteString("0000000000 65535 f \n") + for i := 1; i <= numObjs; i++ { + b.WriteString(fmt.Sprintf("%010d 00000 n \n", offsets[i])) + } + + // Trailer + b.WriteString("trailer\n") + b.WriteString(fmt.Sprintf("<< /Size %d /Root 1 0 R >>\n", numObjs+1)) + b.WriteString("startxref\n") + b.WriteString(fmt.Sprintf("%d\n", xrefOffset)) + b.WriteString("%%EOF\n") + + return b.Bytes() +} diff --git a/internal/extractor/stream_decoder.go b/internal/extractor/stream_decoder.go new file mode 100644 index 0000000..e97533c --- /dev/null +++ b/internal/extractor/stream_decoder.go @@ -0,0 +1,94 @@ +package extractor + +import ( + "compress/zlib" + "fmt" + "io" + + "github.com/coregx/gxpdf/internal/parser" +) + +// decodeStreamData decodes a PDF stream based on its Filter entry. +// +// This is the package-level shared implementation used by both +// TextExtractor and FontExtractor. It handles the most common filter: +// FlateDecode (zlib/deflate). +// +// Returns raw stream bytes when no filter is present. +// Returns raw bytes for unsupported filters so callers can decide. +func decodeStreamData(stream *parser.Stream) ([]byte, error) { + filterObj := stream.Dictionary().Get("Filter") + if filterObj == nil { + // No filter — return raw content directly. + return stream.Content(), nil + } + + filterName := extractFirstFilterName(filterObj) + + switch filterName { + case filterFlateDecode: + return decodeFlateDecode(stream.Content()) + case "": + return stream.Content(), nil + default: + // Return raw bytes for unsupported filters; callers decide. + return stream.Content(), nil + } +} + +// extractFirstFilterName extracts a single filter name from a /Filter entry. +// +// /Filter can be either a Name (/FlateDecode) or an Array ([/FlateDecode]). +// For multi-filter chains we return only the first element — the callers +// in this codebase only ever encounter single-filter streams for fonts. +func extractFirstFilterName(filterObj parser.PdfObject) string { + if name, ok := filterObj.(*parser.Name); ok { + return name.Value() + } + if arr, ok := filterObj.(*parser.Array); ok && arr.Len() > 0 { + if name, ok := arr.Get(0).(*parser.Name); ok { + return name.Value() + } + } + return "" +} + +// decodeFlateDecode decompresses zlib/deflate-compressed data. +// +// This is the canonical FlateDecode implementation in the extractor package. +// Both TextExtractor.decodeFlateDecode and FontExtractor delegate here so +// the logic lives in one place. +func decodeFlateDecode(data []byte) ([]byte, error) { + rc, err := zlib.NewReader(newBytesRC(data)) + if err != nil { + return nil, fmt.Errorf("zlib reader: %w", err) + } + defer func() { _ = rc.Close() }() + + decoded, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("FlateDecode: %w", err) + } + return decoded, nil +} + +// newBytesRC wraps a byte slice in an io.ReadCloser for zlib.NewReader. +func newBytesRC(data []byte) io.ReadCloser { + return &sharedBytesRC{data: data} +} + +type sharedBytesRC struct { + data []byte + pos int +} + +func (b *sharedBytesRC) Read(p []byte) (int, error) { + if b.pos >= len(b.data) { + return 0, io.EOF + } + n := copy(p, b.data[b.pos:]) + b.pos += n + return n, nil +} + +func (b *sharedBytesRC) Close() error { return nil } diff --git a/internal/fonts/load_from_bytes_test.go b/internal/fonts/load_from_bytes_test.go new file mode 100644 index 0000000..8790318 --- /dev/null +++ b/internal/fonts/load_from_bytes_test.go @@ -0,0 +1,176 @@ +package fonts + +import ( + "os" + "testing" +) + +// dejaVuFontPath is defined in coverage_boost_test.go within this package. +// We reuse it here to avoid duplication. + +// TestLoadTTFFromBytes_RoundTrip verifies that LoadTTFFromBytes produces the +// same parsed metrics as LoadTTF when given the same raw bytes. +func TestLoadTTFFromBytes_RoundTrip(t *testing.T) { + // Load from file first. + fileFont, err := LoadTTF(dejaVuFontPath) + if err != nil { + t.Skipf("DejaVu font not available: %v", err) + } + + // Load the same bytes via LoadTTFFromBytes. + byteFont, err := LoadTTFFromBytes(fileFont.FontData) + if err != nil { + t.Fatalf("LoadTTFFromBytes failed: %v", err) + } + + if byteFont == nil { + t.Fatal("LoadTTFFromBytes returned nil") + } + + // FilePath should be empty (loaded from bytes, not disk). + if byteFont.FilePath != "" { + t.Errorf("FilePath = %q, want empty string for in-memory font", byteFont.FilePath) + } + + // Core metrics must match. + if byteFont.PostScriptName != fileFont.PostScriptName { + t.Errorf("PostScriptName: from bytes=%q, from file=%q", byteFont.PostScriptName, fileFont.PostScriptName) + } + if byteFont.UnitsPerEm != fileFont.UnitsPerEm { + t.Errorf("UnitsPerEm: from bytes=%d, from file=%d", byteFont.UnitsPerEm, fileFont.UnitsPerEm) + } + if byteFont.Ascender != fileFont.Ascender { + t.Errorf("Ascender: from bytes=%d, from file=%d", byteFont.Ascender, fileFont.Ascender) + } + if byteFont.Descender != fileFont.Descender { + t.Errorf("Descender: from bytes=%d, from file=%d", byteFont.Descender, fileFont.Descender) + } + if byteFont.CapHeight != fileFont.CapHeight { + t.Errorf("CapHeight: from bytes=%d, from file=%d", byteFont.CapHeight, fileFont.CapHeight) + } + + // Character mapping completeness. + if len(byteFont.CharToGlyph) != len(fileFont.CharToGlyph) { + t.Errorf("CharToGlyph len: from bytes=%d, from file=%d", + len(byteFont.CharToGlyph), len(fileFont.CharToGlyph)) + } + + // Glyph widths completeness. + if len(byteFont.GlyphWidths) != len(fileFont.GlyphWidths) { + t.Errorf("GlyphWidths len: from bytes=%d, from file=%d", + len(byteFont.GlyphWidths), len(fileFont.GlyphWidths)) + } + + // FontData must be preserved as-is. + if len(byteFont.FontData) != len(fileFont.FontData) { + t.Errorf("FontData len: from bytes=%d, from file=%d", + len(byteFont.FontData), len(fileFont.FontData)) + } +} + +// TestLoadTTFFromBytes_MetricsValid verifies basic sanity of parsed metrics. +func TestLoadTTFFromBytes_MetricsValid(t *testing.T) { + fileFont, err := LoadTTF(dejaVuFontPath) + if err != nil { + t.Skipf("DejaVu font not available: %v", err) + } + + byteFont, err := LoadTTFFromBytes(fileFont.FontData) + if err != nil { + t.Fatalf("LoadTTFFromBytes failed: %v", err) + } + + if byteFont.UnitsPerEm == 0 { + t.Error("UnitsPerEm should not be 0") + } + if byteFont.Ascender <= 0 { + t.Errorf("Ascender should be positive, got %d", byteFont.Ascender) + } + if byteFont.Descender >= 0 { + t.Errorf("Descender should be negative, got %d", byteFont.Descender) + } + if len(byteFont.CharToGlyph) == 0 { + t.Error("CharToGlyph should not be empty") + } + if len(byteFont.GlyphWidths) == 0 { + t.Error("GlyphWidths should not be empty") + } + + // ASCII characters should all be present in a real font. + for _, ch := range "ABCabc012" { + if _, ok := byteFont.CharToGlyph[ch]; !ok { + t.Errorf("ASCII char %q not in CharToGlyph", ch) + } + } +} + +// TestLoadTTFFromBytes_InvalidData verifies rejection of non-TTF bytes. +func TestLoadTTFFromBytes_InvalidData(t *testing.T) { + _, err := LoadTTFFromBytes([]byte("this is not a TTF font")) + if err == nil { + t.Error("LoadTTFFromBytes should fail for invalid data") + } +} + +// TestLoadTTFFromBytes_EmptyData verifies rejection of empty byte slice. +func TestLoadTTFFromBytes_EmptyData(t *testing.T) { + _, err := LoadTTFFromBytes([]byte{}) + if err == nil { + t.Error("LoadTTFFromBytes should fail for empty data") + } +} + +// TestLoadTTFFromBytes_Nil verifies rejection of nil byte slice. +func TestLoadTTFFromBytes_Nil(t *testing.T) { + _, err := LoadTTFFromBytes(nil) + if err == nil { + t.Error("LoadTTFFromBytes should fail for nil data") + } +} + +// TestLoadTTFFromBytes_PreservesAllBytes verifies FontData is not truncated or altered. +func TestLoadTTFFromBytes_PreservesAllBytes(t *testing.T) { + data, err := os.ReadFile(dejaVuFontPath) + if err != nil { + t.Skipf("DejaVu font not available: %v", err) + } + + byteFont, err := LoadTTFFromBytes(data) + if err != nil { + t.Fatalf("LoadTTFFromBytes failed: %v", err) + } + + if len(byteFont.FontData) != len(data) { + t.Errorf("FontData len mismatch: got %d, want %d", len(byteFont.FontData), len(data)) + } + for i := range data { + if byteFont.FontData[i] != data[i] { + t.Errorf("FontData differs at byte %d", i) + break + } + } +} + +// TestLoadTTFFromBytes_CanBuildSubset verifies that a font loaded from bytes +// can have a subset built from it, confirming full round-trip usability. +func TestLoadTTFFromBytes_CanBuildSubset(t *testing.T) { + fileFont, err := LoadTTF(dejaVuFontPath) + if err != nil { + t.Skipf("DejaVu font not available: %v", err) + } + + byteFont, err := LoadTTFFromBytes(fileFont.FontData) + if err != nil { + t.Fatalf("LoadTTFFromBytes failed: %v", err) + } + + subset := NewFontSubset(byteFont) + subset.UseString("Hello PDF") + + if err := subset.Build(); err != nil { + t.Fatalf("Build() on bytes-loaded font failed: %v", err) + } + if len(subset.SubsetData) == 0 { + t.Error("SubsetData should be non-empty after Build()") + } +} diff --git a/internal/fonts/ttf_parser.go b/internal/fonts/ttf_parser.go index 994b3d3..3460c2b 100644 --- a/internal/fonts/ttf_parser.go +++ b/internal/fonts/ttf_parser.go @@ -142,6 +142,31 @@ func LoadTTF(path string) (*TTFFont, error) { return font, nil } +// LoadTTFFromBytes parses a TrueType/OpenType font from an in-memory byte slice. +// +// This is the counterpart to LoadTTF for cases where font data is already in +// memory — for example, when a font has been extracted from an existing PDF via +// Document.GetEmbeddedFonts. +// +// The data slice is stored as FontData so the font can be re-embedded without +// re-reading from disk. FilePath is left empty; callers may set it after +// loading if the original path is known. +// +// Returns an error if the bytes do not represent a valid TrueType or OpenType +// font with TrueType outlines. +func LoadTTFFromBytes(data []byte) (*TTFFont, error) { + font := &TTFFont{ + Tables: make(map[string]*TTFTable), + GlyphWidths: make(map[uint16]uint16), + CharToGlyph: make(map[rune]uint16), + FontData: data, + } + if err := font.parse(data); err != nil { + return nil, fmt.Errorf("parse TTF from bytes: %w", err) + } + return font, nil +} + // parse parses the font file structure. func (f *TTFFont) parse(data []byte) error { r := bytes.NewReader(data)