Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
continue-on-error: true
with:
use_oidc: true
file: ./coverage.txt
files: ./coverage.txt
flags: unittests
name: codecov-unit

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed
- **Test Coverage** — Project-wide coverage raised from 53.7% to 86.5%
- 11,200+ lines of new enterprise-grade tests across 12 packages
- All non-example packages now above 80% coverage threshold
- Wave 1: parser 83%, fonts 84.7%, builder/internal 92.3%, creator 83%
- Wave 2: encoding 91.3%, tabledetect 86.9%, document 98.4%
- Wave 3: writer 82.8%, extractor 83.8%, forms 84%, export 84.5%, root 86.5%
- **Lint** — Extracted `filterFlateDecode` constant in extractor (goconst fix)

---

## [0.7.0] - 2026-03-24 "Builder & Signatures"

### Added
Expand Down
340 changes: 340 additions & 0 deletions builder/internal/coverage_boost_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
package internal

import (
"strings"
"testing"

"github.com/coregx/gxpdf/creator"
"github.com/coregx/gxpdf/layout"
)

// ============================================================================
// FontBridge.breakCJK — previously 0% coverage
// ============================================================================

func TestFontBridge_BreakCJK_ShortText(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "Helvetica"}

// Short CJK text that fits on one line.
lines := fb.breakCJK(font, "你好", 12, 1000)
if len(lines) != 1 {
t.Errorf("short CJK text in wide area should produce 1 line, got %d: %v", len(lines), lines)
}
if lines[0] != "你好" {
t.Errorf("line = %q, want %q", lines[0], "你好")
}
}

func TestFontBridge_BreakCJK_ForcesBreak(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "Helvetica"}

// Very narrow width forces break at individual CJK characters.
text := "你好世界"
lines := fb.breakCJK(font, text, 12, 1) // 1 point: impossible to fit even one char
// Should produce at least 1 line (each char on its own).
if len(lines) == 0 {
t.Fatal("breakCJK should return at least one line")
}
// All characters must be preserved across lines.
joined := strings.Join(lines, "")
if joined != text {
t.Errorf("breakCJK lost characters: got %q, want %q", joined, text)
}
}

func TestFontBridge_BreakCJK_EmptyString(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "Helvetica"}

lines := fb.breakCJK(font, "", 12, 100)
// Empty string produces zero lines (no current to flush).
if len(lines) != 0 {
t.Errorf("breakCJK on empty string should return [], got %v", lines)
}
}

// ============================================================================
// FontBridge.LineBreak with CJK text — exercises CJK code path in LineBreak
// ============================================================================

func TestFontBridge_LineBreak_CJKText(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "Helvetica"}

// CJK text with reasonable width.
lines := fb.LineBreak(font, "你好世界Hello", 12, 1000)
if len(lines) == 0 {
t.Fatal("LineBreak should return at least one line")
}
// All content should be preserved.
joined := strings.Join(lines, " ")
if !strings.Contains(joined, "Hello") {
t.Errorf("LineBreak lost non-CJK word 'Hello': %v", lines)
}
}

func TestFontBridge_LineBreak_CJKWithBreaking(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "Helvetica"}

// Pure CJK text that will need wrapping.
text := "一二三四五六七八九十"
lines := fb.LineBreak(font, text, 12, 20) // very narrow
if len(lines) == 0 {
t.Fatal("LineBreak should return at least one line for CJK text")
}
// Preserve all runes.
joined := strings.Join(lines, "")
if joined != text {
t.Errorf("LineBreak lost CJK characters: got %q, want %q", joined, text)
}
}

func TestFontBridge_LineBreak_WhitespaceOnly(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "Helvetica"}

// Whitespace-only input returns [""].
lines := fb.LineBreak(font, " ", 12, 100)
if len(lines) != 1 || lines[0] != "" {
t.Errorf("whitespace-only should return [\"\"], got %v", lines)
}
}

func TestFontBridge_LineBreak_LongWordThatFits(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "Helvetica"}

// Single long word that fits.
lines := fb.LineBreak(font, "Hello", 12, 1000)
if len(lines) != 1 || lines[0] != "Hello" {
t.Errorf("single fitting word should return [Hello], got %v", lines)
}
}

// ============================================================================
// FontBridge.resolveStandard14 — remaining branches: Symbol and ZapfDingbats
// ============================================================================

func TestFontBridge_ResolveStandard14_Symbol(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "symbol"}
name := string(fb.resolveStandard14(font))
if name != string(creator.Symbol) {
t.Errorf("symbol -> %q, want %q", name, creator.Symbol)
}
}

func TestFontBridge_ResolveStandard14_ZapfDingbats(t *testing.T) {
fb := newTestBridge()
font := layout.FontRef{Family: "zapfdingbats"}
name := string(fb.resolveStandard14(font))
if name != string(creator.ZapfDingbats) {
t.Errorf("zapfdingbats -> %q, want %q", name, creator.ZapfDingbats)
}
}

func TestFontBridge_ResolveStandard14_TimesVariants(t *testing.T) {
fb := newTestBridge()
tests := []struct {
font layout.FontRef
want creator.FontName
}{
{layout.FontRef{Family: "times", Weight: layout.WeightBold, Style: layout.StyleItalic}, creator.TimesBoldItalic},
{layout.FontRef{Family: "times new roman", Weight: layout.WeightBold}, creator.TimesBold},
{layout.FontRef{Family: "times new roman", Style: layout.StyleItalic}, creator.TimesItalic},
{layout.FontRef{Family: "times new roman"}, creator.TimesRoman},
}
for _, tc := range tests {
got := fb.resolveStandard14(tc.font)
if got != tc.want {
t.Errorf("resolveStandard14(%v) = %q, want %q", tc.font, got, tc.want)
}
}
}

func TestFontBridge_ResolveStandard14_CourierVariants(t *testing.T) {
fb := newTestBridge()
tests := []struct {
font layout.FontRef
want creator.FontName
}{
{layout.FontRef{Family: "courier", Weight: layout.WeightBold, Style: layout.StyleItalic}, creator.CourierBoldOblique},
{layout.FontRef{Family: "courier", Weight: layout.WeightBold}, creator.CourierBold},
{layout.FontRef{Family: "courier", Style: layout.StyleItalic}, creator.CourierOblique},
}
for _, tc := range tests {
got := fb.resolveStandard14(tc.font)
if got != tc.want {
t.Errorf("resolveStandard14(%v) = %q, want %q", tc.font, got, tc.want)
}
}
}

// ============================================================================
// PDFRenderer — drawJustifiedText path (via WordSpacing > 0 in DrawText)
// ============================================================================

func TestRenderDocument_JustifiedText(t *testing.T) {
cr := creator.New()
r := NewPDFRenderer(cr, nil)

font := layout.FontRef{Family: "Helvetica"}
color := layout.Black

pages := []layout.PageLayout{
{
Size: layout.Size{Width: 595, Height: 842},
Blocks: []layout.Block{
{
X: 50, Y: 50, Width: 400, Height: 20,
Draw: func(renderer layout.Renderer) {
// WordSpacing > 0 triggers drawJustifiedText path.
renderer.DrawText("Hello World PDF", 0, 0, font, 12, color, layout.TextDrawOptions{
WordSpacing: 5.0,
})
},
},
},
},
}
err := r.RenderDocument(pages, ExportedMetadata("", ""))
if err != nil {
t.Fatalf("RenderDocument with justified text failed: %v", err)
}
}

func TestRenderDocument_JustifiedText_SingleWord(t *testing.T) {
cr := creator.New()
r := NewPDFRenderer(cr, nil)

font := layout.FontRef{Family: "Helvetica"}
color := layout.Black

pages := []layout.PageLayout{
{
Size: layout.Size{Width: 595, Height: 842},
Blocks: []layout.Block{
{
X: 50, Y: 50, Width: 400, Height: 20,
Draw: func(renderer layout.Renderer) {
// Single word with word spacing — drawJustifiedText with 1 word.
renderer.DrawText("Hello", 0, 0, font, 12, color, layout.TextDrawOptions{
WordSpacing: 3.0,
})
},
},
},
},
}
err := r.RenderDocument(pages, ExportedMetadata("", ""))
if err != nil {
t.Fatalf("RenderDocument with single-word justified text failed: %v", err)
}
}

// ============================================================================
// PDFRenderer — empty text in DrawText (early return branch)
// ============================================================================

func TestRenderDocument_EmptyTextEarlyReturn(t *testing.T) {
cr := creator.New()
r := NewPDFRenderer(cr, nil)

font := layout.FontRef{Family: "Helvetica"}
color := layout.Black

pages := []layout.PageLayout{
{
Size: layout.Size{Width: 595, Height: 842},
Blocks: []layout.Block{
{
X: 50, Y: 50, Width: 400, Height: 20,
Draw: func(renderer layout.Renderer) {
// Empty string → early return in DrawText.
renderer.DrawText("", 0, 0, font, 12, color, layout.TextDrawOptions{})
},
},
},
},
}
err := r.RenderDocument(pages, ExportedMetadata("", ""))
if err != nil {
t.Fatalf("RenderDocument with empty text failed: %v", err)
}
}

// ============================================================================
// PDFRenderer — DrawRect with no fill/no stroke (nil options)
// ============================================================================

func TestRenderDocument_DrawRect_NilFillAndStroke(t *testing.T) {
cr := creator.New()
r := NewPDFRenderer(cr, nil)

pages := []layout.PageLayout{
{
Size: layout.Size{Width: 595, Height: 842},
Blocks: []layout.Block{
{
X: 50, Y: 50, Width: 100, Height: 50,
Draw: func(renderer layout.Renderer) {
// nil fill and nil stroke.
renderer.DrawRect(0, 0, 100, 50, nil, nil, 0)
},
},
},
},
}
err := r.RenderDocument(pages, ExportedMetadata("", ""))
if err != nil {
t.Fatalf("RenderDocument with nil fill/stroke failed: %v", err)
}
}

func TestRenderDocument_DrawLine_DefaultWidth(t *testing.T) {
cr := creator.New()
r := NewPDFRenderer(cr, nil)
color := layout.Black

pages := []layout.PageLayout{
{
Size: layout.Size{Width: 595, Height: 842},
Blocks: []layout.Block{
{
X: 50, Y: 100, Width: 400, Height: 1,
Draw: func(renderer layout.Renderer) {
// width == 0 → defaults to 1.0 inside DrawLine.
renderer.DrawLine(0, 0, 100, 0, color, 0)
},
},
},
},
}
err := r.RenderDocument(pages, ExportedMetadata("", ""))
if err != nil {
t.Fatalf("RenderDocument with zero-width line failed: %v", err)
}
}

// ============================================================================
// FontBridge — Descender path with negative custom font descender
// ============================================================================

func TestFontBridge_Descender_Standard14NonNegative(t *testing.T) {
fb := newTestBridge()

fonts := []layout.FontRef{
{Family: "Helvetica"},
{Family: "Times"},
{Family: "Courier"},
}
for _, font := range fonts {
d := fb.Descender(font, 12)
if d < 0 {
t.Errorf("Descender(%s, 12) = %f, should always be >= 0", font.Family, d)
}
}
}
Loading