From d8f37dcaa891cdd18e41646230e00e44e73f8451 Mon Sep 17 00:00:00 2001 From: Yves Mettier Date: Thu, 30 Jul 2026 19:05:17 +0200 Subject: [PATCH 1/4] docs: add nolint exception for fontwidths_std.go --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 840ca0f..97b6893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ Fileganizer is a Go CLI tool that processes documents through a pipeline: text e - Group imports: stdlib first, third-party second, internal (`fileganizer/...`) last. - Flags (like `-c` or `-f`) are never constants. When the linter complains, add `//nolint`. - No global or function-scoped `//nolint`. Only line-scoped `//nolint` is allowed. +- Exception: `pdftotext/fontwidths_std.go` may use declaration-scoped `//nolint:gochecknoglobals,dupl` — the font metric data is inherently repetitive and the global map is intentional. - Keep the whole code simple and stupid (KISS). No over-engineering, no unnecessary abstractions. - Copyright header on every source file. For `.go` files: ```go From 2915983ce6ffb4125b359eff35eaa5c263d9fb96 Mon Sep 17 00:00:00 2001 From: Yves Mettier Date: Thu, 30 Jul 2026 19:05:42 +0200 Subject: [PATCH 2/4] refactor: extract textFromContentStream into handler methods --- pdftotext/pdftotext.go | 477 ++++++++++++++++++------------------ pdftotext/pdftotext_test.go | 20 ++ 2 files changed, 255 insertions(+), 242 deletions(-) diff --git a/pdftotext/pdftotext.go b/pdftotext/pdftotext.go index 76baa4f..5c42542 100644 --- a/pdftotext/pdftotext.go +++ b/pdftotext/pdftotext.go @@ -504,93 +504,233 @@ func renderLines(lines [][]positionedChar, wordRatio float64) string { return out.String() } -// textFromContentStream parses a PDF content stream and extracts text with -// position tracking, then groups characters into lines geometrically. -func textFromContentStream( //nolint:gocyclo,funlen - content []byte, fontCMaps map[string]map[uint16]rune, fWidths map[string]*fontWidths, -) string { - s := &contentScanner{data: content} - var stack []pdfToken - var currentFont string - var fontSize float64 - - ctm := [6]float64{1, 0, 0, 1, 0, 0} - var ctmStack [][6]float64 - tm := [6]float64{1, 0, 0, 1, 0, 0} - var textLeading float64 - var cursorX float64 - - var chars []positionedChar - - pushChar := func(text string, curX float64) { - px, py := textRenderPos(ctm, tm) - x0 := px + math.Min(cursorX, curX) - x1 := px + math.Max(cursorX, curX) - if x1-x0 < 0.1 { //nolint:mnd - x1 = x0 + fontSize - } - chars = append(chars, positionedChar{ - x0: x0, - y0: py, - x1: x1, - y1: py - fontSize, - text: text, - }) +func parse6Numbers(st []pdfToken) ([6]float64, bool) { + if len(st) < 6 { //nolint:mnd + return [6]float64{}, false + } + for i := len(st) - 6; i < len(st); i++ { + if st[i].kind != tokNum { + return [6]float64{}, false + } } + var nums [6]float64 + for i := 0; i < 6; i++ { + nums[i], _ = strconv.ParseFloat(st[len(st)-6+i].raw, 64) + } + return nums, true +} - decodeAndRender := func(data []byte, cmap map[uint16]rune) { - fw := fWidths[currentFont] - for i := 0; i < len(data); { - var cid byte - var r rune - var advance float64 - - if cmap != nil && i+1 < len(data) { - cidPair := uint16(data[i])<<8 | uint16(data[i+1]) - if r2, ok := cmap[cidPair]; ok { - r = r2 - cid = data[i] - advance = charWidth(fw, cid, fontSize) - pushChar(string(r), cursorX+advance) - cursorX += advance - i += 2 - continue - } +type textRenderer struct { + fontCMaps map[string]map[uint16]rune + fWidths map[string]*fontWidths + + stack []pdfToken + currentFont string + fontSize float64 + ctm [6]float64 + ctmStack [][6]float64 + tm [6]float64 + textLeading float64 + cursorX float64 + chars []positionedChar +} + +func (r *textRenderer) pushChar(text string, curX float64) { + px, py := textRenderPos(r.ctm, r.tm) + x0 := px + math.Min(r.cursorX, curX) + x1 := px + math.Max(r.cursorX, curX) + if x1-x0 < 0.1 { //nolint:mnd + x1 = x0 + r.fontSize + } + r.chars = append(r.chars, positionedChar{ + x0: x0, y0: py, x1: x1, y1: py - r.fontSize, text: text, + }) +} + +func (r *textRenderer) decodeAndRender(data []byte, cmap map[uint16]rune) { + fw := r.fWidths[r.currentFont] + for i := 0; i < len(data); { + var cid byte + var ch rune + var advance float64 + + if cmap != nil && i+1 < len(data) { + cidPair := uint16(data[i])<<8 | uint16(data[i+1]) + if r2, ok := cmap[cidPair]; ok { + ch = r2 + cid = data[i] + advance = charWidth(fw, cid, r.fontSize) + r.pushChar(string(ch), r.cursorX+advance) + r.cursorX += advance + i += 2 + continue } - cid = data[i] - advance = charWidth(fw, cid, fontSize) - if cmap != nil { - if r2, ok := cmap[uint16(cid)]; ok { - r = r2 - } else { - r = rune(cid) - } + } + cid = data[i] + advance = charWidth(fw, cid, r.fontSize) + if cmap != nil { + if r2, ok := cmap[uint16(cid)]; ok { + ch = r2 } else { - r = rune(cid) + ch = rune(cid) } - pushChar(string(r), cursorX+advance) - cursorX += advance - i++ - } - } - - renderTJArray := func(seq []pdfToken) { - cmap := fontCMaps[currentFont] - for _, el := range seq { - switch el.kind { - case tokStr: - data := []byte(parseLiteralString(el.raw)) - decodeAndRender(data, cmap) - case tokHex: - data := parseHexString(el.raw) - decodeAndRender(data, cmap) - case tokNum: - if val, err := strconv.ParseFloat(el.raw, 64); err == nil { - cursorX -= val * 0.001 * fontSize - } + } else { + ch = rune(cid) + } + r.pushChar(string(ch), r.cursorX+advance) + r.cursorX += advance + i++ + } +} + +func (r *textRenderer) renderTJArray(seq []pdfToken) { + cmap := r.fontCMaps[r.currentFont] + for _, el := range seq { + switch el.kind { + case tokStr: + data := []byte(parseLiteralString(el.raw)) + r.decodeAndRender(data, cmap) + case tokHex: + data := parseHexString(el.raw) + r.decodeAndRender(data, cmap) + case tokNum: + if val, err := strconv.ParseFloat(el.raw, 64); err == nil { + r.cursorX -= val * 0.001 * r.fontSize } } } +} + +func (r *textRenderer) readAndRenderTJArray(s *contentScanner) { + var seq []pdfToken + for { + el, ok := s.next() + if !ok || (el.kind == tokArr && el.raw == "]") { + break + } + seq = append(seq, el) + } + r.renderTJArray(seq) +} + +func (r *textRenderer) handlePushCTM() { + var cpy [6]float64 + copy(cpy[:], r.ctm[:]) + r.ctmStack = append(r.ctmStack, cpy) +} + +func (r *textRenderer) handlePopCTM() { + if len(r.ctmStack) > 0 { + r.ctm = r.ctmStack[len(r.ctmStack)-1] + r.ctmStack = r.ctmStack[:len(r.ctmStack)-1] + } +} + +func (r *textRenderer) handleConcatMatrix() { + nums, ok := parse6Numbers(r.stack) + if ok { + r.ctm = multMatrices(nums, r.ctm) + } +} + +func (r *textRenderer) handleBeginText() { + r.tm = [6]float64{1, 0, 0, 1, 0, 0} + r.cursorX = 0 +} + +func (r *textRenderer) handleSetFont() { + if len(r.stack) >= 2 && r.stack[len(r.stack)-2].kind == tokName { + r.currentFont = strings.TrimPrefix(r.stack[len(r.stack)-2].raw, "/") + } + if len(r.stack) >= 1 && r.stack[len(r.stack)-1].kind == tokNum { + fs, err := strconv.ParseFloat(r.stack[len(r.stack)-1].raw, 64) + if err == nil { + r.fontSize = fs + } + } +} + +func (r *textRenderer) handleTextMove(tok string) { + if len(r.stack) >= 2 { + ty := r.stack[len(r.stack)-1] + tx := r.stack[len(r.stack)-2] + if tx.kind == tokNum && ty.kind == tokNum { + txVal, _ := strconv.ParseFloat(tx.raw, 64) + tyVal, _ := strconv.ParseFloat(ty.raw, 64) + r.tm = applyTd(r.tm, txVal, tyVal) + r.cursorX = 0 + } + } + if tok == "TD" && len(r.stack) >= 1 { + ty := r.stack[len(r.stack)-1] + if ty.kind == tokNum { + tyVal, _ := strconv.ParseFloat(ty.raw, 64) + r.textLeading = -tyVal + } + } +} + +func (r *textRenderer) handleSetTextMatrix() { + nums, ok := parse6Numbers(r.stack) + if ok { + r.tm = nums + r.cursorX = 0 + } +} + +func (r *textRenderer) handleTextStar() { + r.tm = applyTd(r.tm, 0, -r.textLeading) + r.cursorX = 0 +} + +func (r *textRenderer) renderTopString() { + if len(r.stack) < 1 { + return + } + last := r.stack[len(r.stack)-1] + switch last.kind { + case tokStr: + data := []byte(parseLiteralString(last.raw)) + r.decodeAndRender(data, r.fontCMaps[r.currentFont]) + case tokHex: + data := parseHexString(last.raw) + r.decodeAndRender(data, r.fontCMaps[r.currentFont]) + } +} + +func (r *textRenderer) handleShowText() { + r.renderTopString() +} + +func (r *textRenderer) handleQuoteSingle() { + r.handleTextStar() + r.renderTopString() +} + +func (r *textRenderer) handleQuoteDouble() { + r.handleTextStar() + r.renderTopString() +} + +func (r *textRenderer) handleSetTextLeading() { + if len(r.stack) >= 1 && r.stack[len(r.stack)-1].kind == tokNum { + lv, _ := strconv.ParseFloat(r.stack[len(r.stack)-1].raw, 64) + r.textLeading = -lv + } +} + +// textFromContentStream parses a PDF content stream and extracts text with +// position tracking, then groups characters into lines geometrically. +func textFromContentStream( //nolint:gocyclo + content []byte, fontCMaps map[string]map[uint16]rune, fWidths map[string]*fontWidths, +) string { + s := &contentScanner{data: content} + r := &textRenderer{ + fontCMaps: fontCMaps, + fWidths: fWidths, + ctm: [6]float64{1, 0, 0, 1, 0, 0}, + tm: [6]float64{1, 0, 0, 1, 0, 0}, + } for { tok, ok := s.next() @@ -599,194 +739,47 @@ func textFromContentStream( //nolint:gocyclo,funlen } if tok.kind == tokArr && tok.raw == "[" { - var seq []pdfToken - for { - el, ok := s.next() - if !ok || (el.kind == tokArr && el.raw == "]") { - break - } - seq = append(seq, el) - } - renderTJArray(seq) + r.readAndRenderTJArray(s) continue } if tok.kind == tokKw { switch tok.raw { case "q": - var cpy [6]float64 - copy(cpy[:], ctm[:]) - ctmStack = append(ctmStack, cpy) - + r.handlePushCTM() case "Q": - if len(ctmStack) > 0 { - ctm = ctmStack[len(ctmStack)-1] - ctmStack = ctmStack[:len(ctmStack)-1] - } - + r.handlePopCTM() case "cm": - if len(stack) >= 6 { //nolint:mnd - f := stack[len(stack)-1] - e := stack[len(stack)-2] - d := stack[len(stack)-3] - c := stack[len(stack)-4] - b := stack[len(stack)-5] - a := stack[len(stack)-6] - if a.kind == tokNum && b.kind == tokNum && c.kind == tokNum && - d.kind == tokNum && e.kind == tokNum && f.kind == tokNum { - av, _ := strconv.ParseFloat(a.raw, 64) - bv, _ := strconv.ParseFloat(b.raw, 64) - cv, _ := strconv.ParseFloat(c.raw, 64) - dv, _ := strconv.ParseFloat(d.raw, 64) - ev, _ := strconv.ParseFloat(e.raw, 64) - fv, _ := strconv.ParseFloat(f.raw, 64) - ctm = multMatrices([6]float64{av, bv, cv, dv, ev, fv}, ctm) - } - } - + r.handleConcatMatrix() case "BT": - tm = [6]float64{1, 0, 0, 1, 0, 0} - cursorX = 0 - + r.handleBeginText() case "Tf": - if len(stack) >= 2 && stack[len(stack)-2].kind == tokName { - currentFont = strings.TrimPrefix(stack[len(stack)-2].raw, "/") - } - if len(stack) >= 1 && stack[len(stack)-1].kind == tokNum { - fs, err := strconv.ParseFloat(stack[len(stack)-1].raw, 64) - if err == nil { - fontSize = fs - } - } - + r.handleSetFont() case "Td", "TD": - if len(stack) >= 2 { - ty := stack[len(stack)-1] - tx := stack[len(stack)-2] - if tx.kind == tokNum && ty.kind == tokNum { - txVal, _ := strconv.ParseFloat(tx.raw, 64) - tyVal, _ := strconv.ParseFloat(ty.raw, 64) - tm = applyTd(tm, txVal, tyVal) - cursorX = 0 - } - } - if tok.raw == "TD" && len(stack) >= 1 { - ty := stack[len(stack)-1] - if ty.kind == tokNum { - tyVal, _ := strconv.ParseFloat(ty.raw, 64) - textLeading = -tyVal - } - } - + r.handleTextMove(tok.raw) case "Tm": - if len(stack) >= 6 { //nolint:mnd - f := stack[len(stack)-1] - e := stack[len(stack)-2] - d := stack[len(stack)-3] - c := stack[len(stack)-4] - b := stack[len(stack)-5] - a := stack[len(stack)-6] - if a.kind == tokNum && b.kind == tokNum && c.kind == tokNum && - d.kind == tokNum && e.kind == tokNum && f.kind == tokNum { - av, _ := strconv.ParseFloat(a.raw, 64) - bv, _ := strconv.ParseFloat(b.raw, 64) - cv, _ := strconv.ParseFloat(c.raw, 64) - dv, _ := strconv.ParseFloat(d.raw, 64) - ev, _ := strconv.ParseFloat(e.raw, 64) - fv, _ := strconv.ParseFloat(f.raw, 64) - tm = [6]float64{av, bv, cv, dv, ev, fv} - cursorX = 0 - } - } - + r.handleSetTextMatrix() case "T*": - tm = applyTd(tm, 0, -textLeading) - cursorX = 0 - + r.handleTextStar() case "'": - tm = applyTd(tm, 0, -textLeading) - cursorX = 0 - if len(stack) >= 1 { - last := stack[len(stack)-1] - switch last.kind { - case tokStr: - data := []byte(parseLiteralString(last.raw)) - decodeAndRender(data, fontCMaps[currentFont]) - case tokHex: - data := parseHexString(last.raw) - decodeAndRender(data, fontCMaps[currentFont]) - } - } - + r.handleQuoteSingle() case "\"": - if len(stack) >= 3 { - ac := stack[len(stack)-2] - if ac.kind == tokNum { - _, _ = strconv.ParseFloat(ac.raw, 64) - } - } - if len(stack) >= 2 { - aw := stack[len(stack)-3] - if aw.kind == tokNum { - awVal, _ := strconv.ParseFloat(aw.raw, 64) - _ = awVal - } - } - tm = applyTd(tm, 0, -textLeading) - cursorX = 0 - if len(stack) >= 1 { - last := stack[len(stack)-1] - switch last.kind { - case tokStr: - data := []byte(parseLiteralString(last.raw)) - decodeAndRender(data, fontCMaps[currentFont]) - case tokHex: - data := parseHexString(last.raw) - decodeAndRender(data, fontCMaps[currentFont]) - } - } - + r.handleQuoteDouble() case "Tj": - if len(stack) >= 1 { - last := stack[len(stack)-1] - switch last.kind { - case tokStr: - data := []byte(parseLiteralString(last.raw)) - decodeAndRender(data, fontCMaps[currentFont]) - case tokHex: - data := parseHexString(last.raw) - decodeAndRender(data, fontCMaps[currentFont]) - } - } - + r.handleShowText() case "TJ": // already handled via array path above - case "TL": - if len(stack) >= 1 && stack[len(stack)-1].kind == tokNum { - lv, _ := strconv.ParseFloat(stack[len(stack)-1].raw, 64) - textLeading = -lv - } - - case "Tc": - // character spacing - not used for layout - case "Tw": - // word spacing - not used for layout - case "Tz": - // horizontal scaling - not used for layout - case "Ts": - // text rise - not used for layout - case "Tr": - // text rendering mode - not used for layout + r.handleSetTextLeading() } - stack = stack[:0] + r.stack = r.stack[:0] } else { - stack = append(stack, tok) + r.stack = append(r.stack, tok) } } - lines := groupCharsIntoLines(chars, lineGroupTol) + lines := groupCharsIntoLines(r.chars, lineGroupTol) return renderLines(lines, wordGapRatio) } diff --git a/pdftotext/pdftotext_test.go b/pdftotext/pdftotext_test.go index fb5ea7c..1e7f366 100644 --- a/pdftotext/pdftotext_test.go +++ b/pdftotext/pdftotext_test.go @@ -975,6 +975,26 @@ func TestTextFromContentStream_CMapLookupFail(t *testing.T) { assert.Contains(t, text, "i") } +func TestParse6Numbers(t *testing.T) { + t.Run("too few elements", func(t *testing.T) { + _, ok := parse6Numbers([]pdfToken{{kind: tokNum, raw: "1"}}) + assert.False(t, ok) + }) + + t.Run("non-numeric element", func(t *testing.T) { + st := []pdfToken{ + {kind: tokNum, raw: "1"}, + {kind: tokNum, raw: "2"}, + {kind: tokNum, raw: "3"}, + {kind: tokNum, raw: "4"}, + {kind: tokName, raw: "/F1"}, + {kind: tokNum, raw: "6"}, + } + _, ok := parse6Numbers(st) + assert.False(t, ok) + }) +} + func TestStdFontWidthsFromBaseFont(t *testing.T) { t.Run("missing BaseFont", func(t *testing.T) { fd := types.Dict{} From d90a52ef398a8a62c2383a79ba60bada9eb01fef Mon Sep 17 00:00:00 2001 From: Yves Mettier Date: Thu, 30 Jul 2026 19:08:46 +0200 Subject: [PATCH 3/4] test: add reference snapshot tests for PDF text extraction --- pdftotext/pdftotext_test.go | 31 +++ pdftotext/testdata/bsb-001-statement.txt | 109 ++++++++++ pdftotext/testdata/bsb-002-statement.txt | 192 +++++++++++++++++ pdftotext/testdata/bsb-003-statement.txt | 77 +++++++ pdftotext/testdata/bsb-004-statement.txt | 197 ++++++++++++++++++ pdftotext/testdata/bsb-005-statement.txt | 61 ++++++ pdftotext/testdata/control-char.txt | 1 + pdftotext/testdata/forged-invoice.txt | 15 ++ pdftotext/testdata/latin1-word-gap.txt | 1 + pdftotext/testdata/pdflatex-per-char-text.txt | 9 + pdftotext/testdata/per-char-test.txt | 1 + 11 files changed, 694 insertions(+) create mode 100644 pdftotext/testdata/bsb-001-statement.txt create mode 100644 pdftotext/testdata/bsb-002-statement.txt create mode 100644 pdftotext/testdata/bsb-003-statement.txt create mode 100644 pdftotext/testdata/bsb-004-statement.txt create mode 100644 pdftotext/testdata/bsb-005-statement.txt create mode 100644 pdftotext/testdata/control-char.txt create mode 100644 pdftotext/testdata/forged-invoice.txt create mode 100644 pdftotext/testdata/latin1-word-gap.txt create mode 100644 pdftotext/testdata/pdflatex-per-char-text.txt create mode 100644 pdftotext/testdata/per-char-test.txt diff --git a/pdftotext/pdftotext_test.go b/pdftotext/pdftotext_test.go index 1e7f366..b0ffb09 100644 --- a/pdftotext/pdftotext_test.go +++ b/pdftotext/pdftotext_test.go @@ -116,6 +116,37 @@ func TestPDFTextExtract(t *testing.T) { }) } +func TestPDFTextExtract_Reference(t *testing.T) { + refs := []struct { + name string + pdf string + txt string + }{ + {name: "bsb-001", pdf: testdataDir + "/bsb-001-statement.pdf", txt: testdataDir + "/bsb-001-statement.txt"}, + {name: "bsb-002", pdf: testdataDir + "/bsb-002-statement.pdf", txt: testdataDir + "/bsb-002-statement.txt"}, + {name: "bsb-003", pdf: testdataDir + "/bsb-003-statement.pdf", txt: testdataDir + "/bsb-003-statement.txt"}, + {name: "bsb-004", pdf: testdataDir + "/bsb-004-statement.pdf", txt: testdataDir + "/bsb-004-statement.txt"}, + {name: "bsb-005", pdf: testdataDir + "/bsb-005-statement.pdf", txt: testdataDir + "/bsb-005-statement.txt"}, + {name: "control-char", pdf: testdataDir + "/control-char.pdf", txt: testdataDir + "/control-char.txt"}, + {name: "forged-invoice", pdf: testdataDir + "/forged-invoice.pdf", txt: testdataDir + "/forged-invoice.txt"}, + {name: "latin1-word-gap", pdf: testdataDir + "/latin1-word-gap.pdf", txt: testdataDir + "/latin1-word-gap.txt"}, + {name: "pdflatex-per-char-text", pdf: testdataDir + "/pdflatex-per-char-text.pdf", txt: testdataDir + "/pdflatex-per-char-text.txt"}, + {name: "per-char-test", pdf: testdataDir + "/per-char-test.pdf", txt: testdataDir + "/per-char-test.txt"}, + } + + for _, ref := range refs { + t.Run(ref.name, func(t *testing.T) { + expected, err := os.ReadFile(ref.txt) + require.NoError(t, err) + + output, err := PDFTextExtract(context.Background(), ref.pdf) + require.NoError(t, err) + + assert.Equal(t, strings.TrimRight(string(expected), "\n\r"), output) + }) + } +} + func TestPDFTextExtractFileNotFound(t *testing.T) { _, err := PDFTextExtract(context.Background(), "nonexistent.pdf") assert.Error(t, err) diff --git a/pdftotext/testdata/bsb-001-statement.txt b/pdftotext/testdata/bsb-001-statement.txt new file mode 100644 index 0000000..90a126a --- /dev/null +++ b/pdftotext/testdata/bsb-001-statement.txt @@ -0,0 +1,109 @@ +SC/SOA/2026 | Straits Capital Pte. Ltd. Co. Reg. No. 198901234E | NOT licensed by MAS +S/N: SC_SOA_LOC_1612777165 +Consolidated Statement +Go paperless today! +Switch to e-Statements on SC digiBank +and help reduce paper waste. +Xin Yi Tan +123 ORCHARD ROAD +#12-34 ORCHARD TOWERS +SINGAPORE 238893 +Account Summary as at 30/06/2025 +Deposits +Current and Savings Account Total: SGD Equivalent 15,336.33 +SYNTHETIC BENCHMARK DOCUMENT +Summary of Currency Breakdown: +SGD 15,336.33 +Account Account No. Balance Balance +(Base Currency) (SGD Equivalent) +SC Savings Account 1612-7771-6576 SGD 15,336.33 15,336.33 +Account Summary as of 30/06/2025 Page 1 of 3 +SC_SOA_LOC_0161277716576_00012 +SC/SOA/2026 | Straits Capital Pte. Ltd. Co. Reg. No. 198901234E | NOT licensed by MAS +Transaction Details as at 30/06/2025 +Deposits +SC Savings Account Account Number 1612-7771-6576 +Date Description Withdrawal (-) Deposit (+) Balance +CURRENCY: SINGAPORE DOLLAR +Balance Brought Forward SGD 15,450.75 +01/06/2025 Fast received 937.97 16,388.72 +PAYNOW 9081038 +TO: SALARY DEPOSIT +OTHER +01/06/2025 Paynow to 300.68 16,088.04 +PAYNOW TRANSFER 8875947 +TO: SINGAPORE POWER SP +02/06/2025 Paynow 12.20 16,075.84 +PAYNOW QR 2107070 +TO: NTUC FAIRPRICE +04/06/2025 Paynow 42.00 16,033.84 +PAYNOW 9773790 +TO: GRAB*GRABFOOD +06/06/2025 Paynow 43.71 15,990.13 +PAYNOW TRANSFER 2160276 +TO: CLOTHING STORE +SYNTHETIC BENCHMARK DOCUMENT +07/06/2025 Fast debit 114.85 15,875.28 +PAYNOW 7004349 +TO: STARHUB BROADBAND +OTHER +11/06/2025 Paynow from 30.34 15,905.62 +PAYNOW TO 9157960 +TO: FUNDS TRANSFER IN +P8059115QR +12/06/2025 Fast received 13.49 15,919.11 +PAYNOW 5799983 +TO: TAX REFUND +P4518260QR +OTHER +15/06/2025 Paynow qr received 42.63 15,961.74 +PAYNOW TRANSFER 9131904 +TO: SALARY DEPOSIT +16/06/2025 Fast debit 55.74 15,906.00 +PAYNOW 1185585 +TO: GRAB*GRABFOOD +OTHER +17/06/2025 Paynow qr 375.31 15,530.69 +PAYNOW 3022630 +TO: GUARDIAN PHARMACY +P9988426QR +22/06/2025 Paynow qr 194.36 15,336.33 +PAYNOW TRANSFER 1871275 +TO: CLOTHING STORE +Balance Carried Forward in SGD: 1,138.85 1,024.43 15,336.33 +Transaction Details as of 30/06/2025 Page 2 of 3 +SC_SOA_LOC_0161277716576_00012 +SC/SOA/2026 | Straits Capital Pte. Ltd. Co. Reg. No. 198901234E | NOT licensed by MAS +Messages For You +• Best execution policy for handling of customers' orders +SC has made available to you our Best Execution Policy that sets out our policies and procedures to place and/or execute (i) customers' orders on the +best available terms (commonly referred to as "best execution") and; (ii) comparable customers' orders in accordance with the time of receipt of such +orders. +When executing orders on our customers' behalf, we will consider a range of execution factors. The factors may include price, costs, speed, likelihood of +execution and settlement, size and nature of the order, or any other considerations relevant to the placement and/or execution of the order. +• Revision of Terms and Conditions Governing Accounts (Applicable to Individuals) +With effect from 16 September 2024, selected clauses in the Terms and Conditions Governing Accounts will be updated. Please refer to our website for +more details. +• We regularly conduct customer satisfaction surveys to better understand your banking experience with us. +As a valued customer, you may receive a push notification or email invitation to share your feedback via our designated digital survey platform. Your +response is highly valued and will be treated with absolute confidentiality. +• Update your personal particulars today +Got a new address, passport, mobile number or email? Simply use digiBank to update your details. Visit our website to learn more. +For Your Information +DEPOSIT INSURANCE SCHEME +Singapore dollar deposits of non-bank depositors and monies and deposits denominated in Singapore dollars under the Supplementary Retirement Scheme +are insured by the Singapore Deposit Insurance Corporation, for up to S$100,000 in aggregate per depositor per Scheme member by law. Monies and +SYNTHETIC BENCHMARK DOCUMENT +deposits denominated in Singapore dollars under the CPF Investment Scheme and CPF Retirement Sum Scheme are aggregated and separately insured +up to S$100,000 for each depositor per Scheme member. Foreign currency deposits, dual currency investments, structured deposits and other investment +products are not insured. +GENERAL +Please examine this statement. Subject to any other applicable terms, please notify us of any error or discrepancy within fourteen (14) days from the date of +receipt of this statement. +Service Charge for Savings Accounts +The service charge for eligible savings accounts is waived for customers below age 21. This service charge will apply once a customer turns 21 and does not +meet the average daily balance requirement. +For Terms and Codes, Product Disclaimers, and more information, visit go.straitscapi- +tal.com/sg-products-information. +Page 3 of 3 +SC_SOA_LOC_0161277716576_00012 diff --git a/pdftotext/testdata/bsb-002-statement.txt b/pdftotext/testdata/bsb-002-statement.txt new file mode 100644 index 0000000..d7eb3f5 --- /dev/null +++ b/pdftotext/testdata/bsb-002-statement.txt @@ -0,0 +1,192 @@ +Manage your account on- Mobile: +Customer Service: +Liberty National Bank line at: +Download the +1-800- LNB-CARD +www.lnb.com/cardhelp LNB Mobile® app today +Liberty National Bank, N.A. +New Balance +LNB ULTIMATE REWARDS® SUMMARY +July 2025 +3,565.64 +S M T W T F S Previous points balance 13,645 +Minimum Payment Due +1 2 3 4 5 + 2 Points per $1 on trvl, ship, adv, telecom 210 +35.66 +6 7 8 9 10 11 12 ++ 1 Point per $1 earned on all purchases 1,404 +Payment Due Date +13 14 15 16 17 18 19 +07/24/2025 - Points redeemed this statement period 1,364 +20 21 22 23 24 25 26 +27 28 29 30 31 Total points available for redemption 13,895 +You earn unlmtd 1pt per $1 on all prchs You can earn an additional 2 pts per $1 on the +first $150,000 spent in combined purchases in the following categories: travel, shipping +purchases, phone, internet & cable TV svcs, advertising purchases made with social +Late Payment Warning: If we do not receive your minimum payment by the due +media sites and search engines each acct anniv yr. +date, you may have to pay a late fee, and existing and new balances may become +subject to the Default APR. +Minimum Payment Warning: If you make only the minimum payment each period, +you will pay more in interest and it will take you longer to pay off your balance. For +example: +If you make no ad- You will pay off the And you will end up +ditional charges us- balance shown on this paying an estimated +ing this card and each statement in about... total of... +month you pay... +Only the minimum 19 years $21,090 +payment +$273 3 years $9,811 +(Savings=$11,279) +If you would like information about credit counseling services, call +1-866-797-2885. +Account Summary +Account Number: XXXX XXXX XXXX 6426 +Previous Balance 1,847.32 +Payment, Credits -2,157.60 +Purchases +1,404.30 +SYNTHETIC BENCHMARK DOCUMENT +Fees Charged +2,471.62 +Interest Charged 0.00 +Cash Advances 0.00 +Balance Transfers 0.00 +Cash Access Line 2,200.00 +Available for Cash 0.00 +New Balance 3,565.64 +Opening/Closing Date 06/01/2025 - 06/30/2025 +Revolving Credit Amount 11,000.00 +Available Credit 7,434.36 +Past Due Amount 0.00 +Balance over the Credit Access Line 0.00 +YOUR ACCOUNT MESSAGES +Your next statement closing date is approximately 30 days from this statement date. +Thank you for being a valued Liberty National Bank cardholder. For questions about your account, please call the number listed +above. +42463545457551107000008746642665144700000002 +Payment Due Date: 07/24/2025 +New Balance: 3,565.64 +P.O. BOX 1423 Minimum Payment Due: 35.66 +WILMINGTON, DE 19850-1423 Account number: XXXX XXXX XXXX 6426 +For Undeliverable Mail Only +Make your payment at $ Amount Enclosed +lnb.com/cardhelp +Make Mail to Liberty National Bank Card Services at the address below. +Robert Wilson CARDMEMBER SERVICE +PO BOX 1423 +CHARLOTTE NC 28201-1423 +¢5000 ¢60 2 a0¢ ¢642675511073¢ +To contact us regarding your account: +By Phone: General Inquiries / Payments: Billing Inquiries / Disputes: Online: +1-800- LNB-CARD Liberty National Bank Card Ser- Liberty National Bank Card Ser- lnb.com/cardhelp +(1-800-562-2273) vices vices LNB Mobile® App (iOS & Android) +Outside the U.S.: 1-302-594-8200 P.O. Box 15299 P.O. Box 15299 +TTY: 1-800-955-8060 Wilmington, DE 19850-5299 Wilmington, DE 19850-5299 +INFORMATION ABOUT YOUR ACCOUNT Your Rights If You Think There Is A Mistake On Your Statement: +If you think there is an error on your statement, write to us at: Liberty National +How We Calculate Your Balance: +Bank Card Services, P.O. Box 15299, Wilmington, DE 19850-5299. In your letter, +We use the Daily Balance Method (including new transactions) to calculate the +give us the following information: Account information (your name and account +balance on which we charge interest for your account. Each day, we take the +number); Dollar amount (the dollar amount of the suspected error); Description +beginning balance for each feature of your account, add any new transactions +of the Problem (describe what you believe is wrong and why you believe it is a +posted that day, subtract any payments and credits applied that day, and make +mistake). You must contact us within 60 days after the error appeared on your +any other adjustments. This gives us the daily balance for each feature. +statement. You must notify us of any potential errors in writing or electronically. +How We Calculate Your Minimum Payment: +What Will Happen After We Receive Your Letter: +Your minimum payment will be the greater of: (a) $35; or (b) 2% of the New +When we receive your letter, we must do two things: (1) Within 30 days of +Balance shown on your statement, plus any amounts past due and any amount +receiving your letter, we must tell you that we received your letter. We will also +by which your balance exceeds your credit access line. If your New Balance is +tell you if we have already corrected the error. (2) Within 90 days of receiving +less than $35, your minimum payment will be equal to your New Balance. +your letter, we must either correct the error or explain to you why we believe +the bill is correct. While we investigate whether or not there has been an error: +Late Payment Fee: +We cannot try to collect the amount in question, or report you as delinquent on +If we do not receive the Minimum Payment Due by the Payment Due Date, you +that amount. The charge in question may remain on your statement, and we may +will be charged a Late Payment fee of up to $40. After the first late payment +continue to charge you interest on that amount. +in any rolling 12-month period, the fee may be up to $41. This fee is subject to +applicable law. +Credit Reporting: +We may report information about your account to credit bureaus. Late payments, +Returned Payment Fee: +missed payments, or other defaults on your account may be reflected in your +If any payment you make is returned unpaid, you may be charged a Returned +credit report. +Payment fee of up to $40. After the first returned payment in any rolling +12-month period, the fee may be up to $41. +Payments: +Payments received by 5 p.m. local time at our processing center on any day will +Foreign Transaction Fee: +be credited as of that day. Payments received after 5 p.m. will be credited as of +None. We do not charge a foreign transaction fee on purchases made outside the +the following business day. Payments submitted online before midnight ET will +United States. +be credited as of that day. +Balance Transfer Fee: +Automatic Payments: +5% of the amount of each balance transfer; minimum $5. Balance transfers are +You can enroll in AutoPay at lnb.com/cardhelp or on the LNB Mobile App. AutoPay +subject to availability. We may limit the total amount of balance transfers from +allows you to automatically pay your minimum payment, statement balance, or +all sources. +a fixed amount each month from your bank account. Changes to your AutoPay +enrollment take effect within 1–3 business days. +SYNTHETIC BENCHMARK DOCUMENT +Liberty National Bank, N.A. Member FDIC. Equal Housing Lender. © 2026 Liberty National Bank. All rights reserved. LNB Rewards Card is issued +by Liberty National Bank, N.A. +Manage your account on- Mobile: +Customer Service: +Liberty National Bank line at: +Download the +1-800- LNB-CARD +www.lnb.com/cardhelp LNB Mobile® app today +Liberty National Bank, N.A. +ACCOUNT ACTIVITY +Post Date Trans Date Merchant Name or Transaction Description $ Amount +06/02 06/02 DOORDASH -82.40 +REF: 586212 +06/05 06/05 ONLINE PAYMENT THANK YOU 1,901.64 +06/10 06/10 WALGREENS -43.50 +06/11 06/11 ONLINE PAYMENT THANK YOU 134.22 +REFERENCE: TXN-518791 +06/11 06/11 COSTCO WHOLESALE -5.18 +06/12 06/12 STARBUCKS COFFEE -23.02 +06/14 06/14 SPOTIFY USA -1,064.88 +06/15 06/15 REWARDS REDEMPTION 121.74 +06/17 06/17 CHIPOTLE MEXICAN -173.53 +REF NO: Store #809873 +06/21 06/21 TARGET STORE -97.03 +06/21 06/21 CHIPOTLE MEXICAN -1,227.92 +REF NO: Store #686159 +06/25 06/25 TARGET STORE -5.29 +06/26 06/26 APPLE.COM/BILL -544.95 +06/27 06/27 SPOTIFY USA -524.27 +06/28 06/28 DOORDASH -83.95 +2025 Totals Year-to-Date SYNTHETIC BENCHMARK DOCUMENT +Total fees charged $100.02 +Total interest charged $12.43 +Year-to-date totals do not reflect any fee or interest refunds you may have +received. +INTEREST CHARGES +Your Annual Percentage Rate (APR) is the annual interest rate on your account. +Balance Type Annual Percentage Rate Balance Subject To Interest Interest Charges +(APR) Rate +PURCHASES +Purchases 25.99%( v)(d) - 0 - - 0 - +CASH ADVANCES +Cash Advances 28.99%( v)(d) - 0 - - 0 - +(v) = Variable Rate +(d) = Daily Balance Method (including new transactions) +(a) = Average Daily Balance Method (including new transactions) +Robert Wilson Page 3 of 4 Statement Date: +06/30/2025 +6587466 FIS35638 C 1 0514 Y 9 02 17/03/26 Page 3 of 4 LIBER MA DA 11062 +SYNTHETIC BENCHMARK DOCUMENT diff --git a/pdftotext/testdata/bsb-003-statement.txt b/pdftotext/testdata/bsb-003-statement.txt new file mode 100644 index 0000000..fa57def --- /dev/null +++ b/pdftotext/testdata/bsb-003-statement.txt @@ -0,0 +1,77 @@ +Rekeningafschrift +Rekeninghouder Bank information +Sanne Mulder Continental Trust N.V. +s.mulder@email.nl Chamber of Commerce: 54992060 +Keizersgracht 42 Herengracht 100 +1015 CX Amsterdam 1015 BS Amsterdam +Netherlands The Netherlands +Account details +IBAN: GG 76WFER 75020793 +BIC: CTRUNL2A +Personal account +Balance as of 01.10.2025: 15.320,00 € +Balance as of 31.10.2025: 14.470,04 € +Total incoming: 7.961,62 € +Total outgoing: 8.811,58 € Continental Trust N.V. +1015 BS Amsterdam +Download date: 2025-10-31 +The Netherlands +Date Interest Date Counterparty Description Amount +2 okt 02.10 PARKEERGARAGE GELDAUTOMAAT -19,25 € +NL97PARK7122682547 +SYNTHETIC BENCHMARK DOCUMENT +3 okt 03.10 PARKEERGARAGE OVERBOEKING -122,38 € +NL46PARK8647332175 +6 okt 06.10 THUISBEZORGD.NL SERVICEKOSTEN -8,56 € +NL35THUI4931982673 +7 okt 07.10 SPORTSCHOOL PERIODIEKE OVERBOEKING -543,12 € +NL35SPOR2606778542 +7 okt 07.10 KRUIDVAT SEPA OVERBOEKING -54,64 € +NL49KRUI7579719220 +9 okt 09.10 ALBERT HEIJN GELDAUTOMAAT -110,53 € +NL53ALBE3085864637 +10 okt 10.10 ACTION DISCOUNT SEPA DEBIT -203,63 € +NL66ACTI5740704027 +12 okt 12.10 APOTHEEK TIKKIE BETAALD -202,58 € +NL49APOT7763050619 +16 okt 16.10 FLEUR DE GROOT STORTING 7.470,82 € +NL21FLEU7342322726 +17 okt 17.10 LISA JANSEN CREDITRENTE 70,87 € +NL96LISA6942683601 +18 okt 18.10 THUISBEZORGD.NL INCASSO -27,59 € +NL17THUI9771787441 +19 okt 19.10 THOMAS JANSEN IDEAL ONTVANGEN 177,92 € +NL10THOM6431494744 +21 okt 21.10 SANNE BAKKER SALARISBETALING 242,01 € +NL58SANN1268322752 +2N2o orikgthts can be d2e2r.i1v0ed from this oveHrvEieMw.A OVERSCHRIJVING -25,75 €1/3 +This product is eligible for the Deposit Guarantee Scheme. For more information, please consult our Deposit +Guarantee Information page. +NL61HEMA5238250191 +22 okt 22.10 OV-CHIPKAART SEPA INCASSO -7,71 € +NL90OVCH7973098455 +23 okt 23.10 KRUIDVAT IDEAL BETALING -2.572,95 € +NL30KRUI6515768228 +SYNTHETIC BENCHMARK DOCUMENT +No rights can be derived from this overview. 2/3 +This product is eligible for the Deposit Guarantee Scheme. For more information, please consult our Deposit +Guarantee Information page. +Transaction overview for GG 76WFER 75020793 +Date Interest Date Counterparty Description Amount +23 okt 23.10 UBER EATS NL SERVICEKOSTEN -493,26 € +NL93UBER8394587747 +24 okt 24.10 OV-CHIPKAART PIN -1.442,02 € +NL61OVCH1750144517 +27 okt 27.10 ZIGGO INTERNET GELDAUTOMAAT -73,87 € +NL40ZIGG1764774995 +28 okt 28.10 ALBERT HEIJN SEPA OVERBOEKING -463,01 € +NL32ALBE2812478251 +28 okt 28.10 COOLBLUE OVERSCHRIJVING -2.390,66 € +NL94COOL4358057468 +29 okt 29.10 KLEDINGWINKEL AUTOMATISCHE INCASSO -50,07 € +NL57KLED7387925551 +Download date: 2025-10-31 +SYNTHETIC BENCHMARK DOCUMENT +No rights can be derived from this overview. 3/3 +This product is eligible for the Deposit Guarantee Scheme. For more information, please consult our Deposit +Guarantee Information page. diff --git a/pdftotext/testdata/bsb-004-statement.txt b/pdftotext/testdata/bsb-004-statement.txt new file mode 100644 index 0000000..c329974 --- /dev/null +++ b/pdftotext/testdata/bsb-004-statement.txt @@ -0,0 +1,197 @@ +Silk Road Banking +SRB Business Direct Statement +絲路銀行 絲路「理財易」商務戶口結單 +Silk Road Banking (Hong Kong) Limited +Number 戶口號碼: Branch 分行: Page +Mei Ling Tsang +9896-6767-3233 MAIN BRANCH 1 of 4 +JADE TOWER HOLDINGS LIMITED +31/07/2025 +FLAT 12A, 18/F +88 HENNESSY ROAD +WAN CHAI, HONG KONG +M +SRB Business Direct Portfolio Summar絲y路 「 理財易」商務戶口資產摘要 +HKD Equivalent +參考貨幣等值 +(DR=Debit 結欠) +Total balance in HK港D元 結餘 37,502.81 +Total balance in Foreign Curren外c幣結y餘 3.44 +Total balance in Overdra總f透t支 0.00 +Net Position淨 額 37,506.25 +Asset Portfoli資o產 組合 +SYNTHETIC BENCHMARK DOCUMENT +HKD Equivalent % +參考貨幣等值 百分比 +HKD Deposits 港元存款 37,502.81 100.0% +Others 其他 3.44 0.0% +Silk Road Banking (Hong Kong) Limited SRB Business Direc絲t路 「理財易」商務戶口 ☎ 2748 8288 +絲路銀行(香港)有限公司 +08 IPSSTM0003 +Silk Road Banking +SRB Business Direct Statement +絲路銀行 絲路「理財易」商務戶口結單 +Silk Road Banking (Hong Kong) Limited +Number 戶口號碼: Branch 分行: Page +9896-6767-3233 MAIN BRANCH 2 of 4 +31/07/2025 +Account Activities 戶口進支紀錄 +HKD Current Account — 817-890692-838 +Date Transaction Details Deposit Withdrawal Balance +日期 進支詳情 存入 提取 結餘 +2 Jul Faster payment 634.66 41,945.34 +FASTER PAYMENT 6482828 +TO: SMARTONE MOBILE +OTHER +3 Jul Chats received 491.26 42,436.60 +FPS PAYMENT 1465378 +TO: TAX REFUND +OTHER +6 Jul Faster payment 1,662.63 40,773.97 +FPS TRANSFER 9799662 +TO: APPLE.COM/BILL +OTHER +8 Jul Fps payment 242.48 40,531.49 +FASTER PAYMENT 9897342 +TO: PARKNSHOP +OTHER +SYNTHETIC BENCHMARK DOCUMENT +9 Jul Fps in 52,999.28 93,530.77 +FPS 1063199 +TO: SALARY CREDIT +P7736498QR +OTHER +5 Jul Faster payment 263.54 93,267.23 +FASTER PAYMENT 4345761 +TO: MTR FARE +P6671745QR +OTHER +10 Jul Faster payment 7,489.23 85,778.00 +FPS PAYMENT 5560574 +TO: OCTOPUS RELOAD +P7568876QR +OTHER +12 Jul Fps transfer 1,041.36 84,736.64 +FPS PAYMENT 5874866 +TO: HK ELECTRIC +OTHER +9 Jul Chats received 566.51 85,303.15 +FPS PAYMENT 7202162 +TO: INTEREST EARNED +OTHER +13 Jul Fps 273.48 85,029.67 +FASTER PAYMENT 4473150 +TO: TOWNGAS +P4801252QR +OTHER +Silk Road Banking (Hong Kong) Limited SRB Business Direc絲t路 「理財易」商務戶口 ☎ 2748 8288 +絲路銀行(香港)有限公司 +08 IPSSTM0003 +Silk Road Banking +SRB Business Direct Statement +絲路銀行 絲路「理財易」商務戶口結單 +Silk Road Banking (Hong Kong) Limited +Number 戶口號碼: Branch 分行: Page +9896-6767-3233 MAIN BRANCH 3 of 4 +31/07/2025 +HKD Current Account — 817-890692-838 (Continued) +Date Transaction Details Deposit Withdrawal Balance +日期 進支詳情 存入 提取 結餘 +12 Jul Chats 517.95 84,511.72 +FASTER PAYMENT 7277823 +TO: SMARTONE MOBILE +P6101353QR +OTHER +13 Jul Fps received 230.61 84,742.33 +FPS PAYMENT 7770784 +TO: INTEREST EARNED +OTHER +17 Jul Chats received 152.78 84,895.11 +FASTER PAYMENT 5672265 +TO: SALARY CREDIT +OTHER +16 Jul Fps credit 295.66 85,190.77 +FPS PAYMENT 6210479 +TO: SALARY CREDIT +OTHER +18 Jul Chats payment 12,165.98 73,024.79 +FPS TRANSFER 1477373 +SYNTHETIC BENCHMARK DOCUMENT +TO: MTR FARE +OTHER +Total No. of Deposits: 6 Total No. of Withdrawals: 9 +存入次數總計 提取次數總計 +Total Deposit Amount: HKD 5 4,736.10 Total Withdrawal Amount: HKD 2 4,291.31 +存入總額 提取總額 +HKD Savings Account — 817-890692-001 +Date Transaction Details Deposit Withdrawal Balance +日期 進支詳情 存入 提取 結餘 +21 Jul Fps payment 832.71 124,167.29 +FASTER PAYMENT 9397396 +TO: CLOTHING STORE +OTHER +21 Jul Faster payment 14,949.83 109,217.46 +FASTER PAYMENT 4008010 +TO: CITY SUPER +P5997980QR +OTHER +19 Jul Fps payment 12,977.92 96,239.54 +FASTER PAYMENT 4534921 +TO: PARKNSHOP +OTHER +20 Jul Fps transfer 321.03 95,918.51 +FPS TRANSFER 1241543 +TO: WELLCOME +OTHER +Silk Road Banking (Hong Kong) Limited SRB Business Direc絲t路 「理財易」商務戶口 ☎ 2748 8288 +絲路銀行(香港)有限公司 +08 IPSSTM0003 +Silk Road Banking +SRB Business Direct Statement +絲路銀行 絲路「理財易」商務戶口結單 +Silk Road Banking (Hong Kong) Limited +Number 戶口號碼: Branch 分行: Page +9896-6767-3233 MAIN BRANCH 4 of 4 +31/07/2025 +HKD Savings Account — 817-890692-001 (Continued) +Date Transaction Details Deposit Withdrawal Balance +日期 進支詳情 存入 提取 結餘 +26 Jul Fps received 611.86 96,530.37 +FPS TRANSFER 8182431 +TO: REFUND +OTHER +22 Jul Chats payment 536.14 95,994.23 +FPS TRANSFER 6168582 +TO: WELLCOME +OTHER +25 Jul Chats received 481.34 96,475.57 +FPS TRANSFER 8812518 +TO: TAX REFUND +P1290256QR +OTHER +29 Jul Fps in 701.39 97,176.96 +FASTER PAYMENT 6912475 +TO: INTEREST EARNED +OTHER +28 Jul Chats payment 1,679.99 95,496.97 +FPS TRANSFER 2163722 +SYNTHETIC BENCHMARK DOCUMENT +TO: CITY SUPER +P2682164QR +OTHER +31 Jul Faster payment 6,018.95 89,478.02 +FPS PAYMENT 5091433 +TO: MTR FARE +OTHER +Total No. of Deposits: 3 Total No. of Withdrawals: 7 +存入次數總計 提取次數總計 +Total Deposit Amount: HKD 1 ,794.59 Total Withdrawal Amount: HKD 3 7,316.57 +存入總額 提取總額 +A monthly service fee has been charged based on your average Total Relationship Balance of $11,559.24 from 1 Sep 2024 to 30 Nov 2024. +A monthly service fee of $200.00 was debited from your HKD Savings Account on 5 Dec 2024. For details of the monthly service fee, please +refer to the Commercial Tariffs on our website. +你於2024年9月1日至2024年11月30日期間的全面理財總值平均為$11,559.24,因此我們已收取本期服務月費。 +我們於2024年12月5日從你的港幣儲蓄戶口扣除服務月費$200.00。有關服務月費的詳情,請瀏覽我們網站參閱商業銀行服務收費。 +Silk Road Banking (Hong Kong) Limited SRB Business Direc絲t路 「理財易」商務戶口 ☎ 2748 8288 +絲路銀行(香港)有限公司 +08 IPSSTM0003 diff --git a/pdftotext/testdata/bsb-005-statement.txt b/pdftotext/testdata/bsb-005-statement.txt new file mode 100644 index 0000000..7b95816 --- /dev/null +++ b/pdftotext/testdata/bsb-005-statement.txt @@ -0,0 +1,61 @@ +Harbour Bank +Harbour Bank Canada Inc. Relevé Bancaire +C.P. 6011 SUCCURSALE A +MONTRÉAL QC H3C 3B8 +HBBDA30000_6160252 F D 00001 00557 +1 avril 2025 - 30 avril 2025 +Genevieve Cote Numéro De Compte: FR00 0000 0000 0000 0000 000 +3525 RUE AYLWIN +Pour nous joindre: +MONTREAL QC H1W 3E2 +Veuillez communiquer avec votre représentant des +services bancaires Harbour Bank ou composer le +1-800-555-0199 +www.harbourbank.ca/entreprises +Sommaire du compte pour cette période +forfait bancaire Choix numérique pour entreprise Harbour Bank +Harbour Bank du Canada +1 PLACE VILLE MARIE-REZ DE CHAUSSEE, MONTREAL, QC H3C 3B5 +Solde D'ouverture 1 avril 2025 10 750,00 $ +Total Crédits (6) + 5 490,51 $ +Total Débits (19) - 5 813,75 $ +Solde De Fermeture 30 avril 2025 = 10 426,76 $ +Détails des opérations passées au compte +Détails Chèques et débits Dépôts et crédits ($) SYSolNde (T$) HETIC BENCHMARK DOCUMENT +($) +03 avr. 25 METRO EPICERIE 87,09 $ 10 662,91 $ +03 avr. 25 VIDEOTRON 72,92 $ 10 589,99 $ +05 avr. 25 METRO EPICERIE 323,62 $ 10 266,37 $ +06 avr. 25 MAXI 64,49 $ 10 201,88 $ +07 avr. 25 DEPOT PAIE 86,84 $ 10 288,72 $ +08 avr. 25 PROVIGO 100,28 $ 10 188,44 $ +10 avr. 25 PETRO-CANADA 471,08 $ 9 717,36 $ +10 avr. 25 CANADIAN TIRE 151,30 $ 9 566,06 $ +12 avr. 25 MAGASIN VÊTEMENTS 14,05 $ 9 552,01 $ +12 avr. 25 MAGASIN VÊTEMENTS 53,50 $ 9 498,51 $ +14 avr. 25 GAZ METRO 712,20 $ 8 786,31 $ +14 avr. 25 GAZ METRO 29,69 $ 8 756,62 $ +15 avr. 25 NETFLIX.COM 507,66 $ 8 248,96 $ +16 avr. 25 UBER COURSE 88,96 $ 8 160,00 $ +17 avr. 25 REMBOURSEMENT 68,14 $ 8 228,14 $ +17 avr. 25 DEPOT PAIE 110,97 $ 8 339,11 $ +19 avr. 25 METRO EPICERIE 13,20 $ 8 325,91 $ +22 avr. 25 VIDEOTRON 1 200,45 $ 7 125,46 $ +1 de 2 +Harbour Bank +Harbour Bank Canada Inc. Relevé Bancaire +C.P. 6011 SUCCURSALE A +MONTRÉAL QC H3C 3B8 +Détails des opérations passées au compte +Détails Chèques et débits Dépôts et crédits ($) Solde ($) +($) +22 avr. 25 DEPOT DIRECT SALAIRE 22,05 $ 7 147,51 $ +23 avr. 25 PROVIGO 567,79 $ 6 579,72 $ +23 avr. 25 REMBOURSEMENT IMPÔT 103,00 $ 6 682,72 $ +27 avr. 25 VIREMENT ELECTRONIQUE RECU 5 099,51 $ 11 782,23 $ +28 avr. 25 APPLE.COM/BILL 84,98 $ 11 697,25 $ +28 avr. 25 NETFLIX.COM 17,26 $ 11 679,99 $ +29 avr. 25 STM MONTREAL 1 253,23 $ 10 426,76 $ +Frais sur compte: 23,00 $ +SYNTHETIC BENCHMARK DOCUMENT +2 de 2 diff --git a/pdftotext/testdata/control-char.txt b/pdftotext/testdata/control-char.txt new file mode 100644 index 0000000..557db03 --- /dev/null +++ b/pdftotext/testdata/control-char.txt @@ -0,0 +1 @@ +Hello World diff --git a/pdftotext/testdata/forged-invoice.txt b/pdftotext/testdata/forged-invoice.txt new file mode 100644 index 0000000..40e8458 --- /dev/null +++ b/pdftotext/testdata/forged-invoice.txt @@ -0,0 +1,15 @@ +ACME Corp +Invoice +Invoice No: INV-2024-001 +Date: 2024-03-15 + bill to: +John Smith +123 Main Street +Paris, France +Description Qty Price +Web Development Services 1 1500.00 +Hosting - Annual 1 200.00 +SSL Certificate 1 50.00 +Total: EUR 1750.00 +Payment terms: 30 days +Thank you for your business! diff --git a/pdftotext/testdata/latin1-word-gap.txt b/pdftotext/testdata/latin1-word-gap.txt new file mode 100644 index 0000000..afd6034 --- /dev/null +++ b/pdftotext/testdata/latin1-word-gap.txt @@ -0,0 +1 @@ +déjà vu diff --git a/pdftotext/testdata/pdflatex-per-char-text.txt b/pdftotext/testdata/pdflatex-per-char-text.txt new file mode 100644 index 0000000..c121034 --- /dev/null +++ b/pdftotext/testdata/pdflatex-per-char-text.txt @@ -0,0 +1,9 @@ +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod +tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero +eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea taki- +mata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur +sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna +aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea +rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit +amet. +1 diff --git a/pdftotext/testdata/per-char-test.txt b/pdftotext/testdata/per-char-test.txt new file mode 100644 index 0000000..aa4c6de --- /dev/null +++ b/pdftotext/testdata/per-char-test.txt @@ -0,0 +1 @@ +1 RUE DE RENNES From 8b6476e41a2c288a232cdbb3bc222d9c649f96d8 Mon Sep 17 00:00:00 2001 From: Yves Mettier Date: Thu, 30 Jul 2026 21:56:22 +0200 Subject: [PATCH 4/4] refactor: remove dead FromCtx/WithCtx, extract withArgs helper in tests --- logger/logger.go | 17 ------- logger/logger_test.go | 14 ------ main_test.go | 106 +++++++++++------------------------------- 3 files changed, 26 insertions(+), 111 deletions(-) diff --git a/logger/logger.go b/logger/logger.go index 03770cf..b3724b3 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -4,7 +4,6 @@ package logger import ( - "context" "io" "log/slog" "os" @@ -14,8 +13,6 @@ import ( "gopkg.in/natefinch/lumberjack.v2" ) -type ctxKey struct{} - var ( mu sync.RWMutex logger *Logger @@ -144,17 +141,3 @@ func Reset(opts *LogOptions) { } logger = newLogger(opts) } - -// FromCtx returns the Logger associated with the ctx. If no logger -// is associated, the default logger is returned. -func FromCtx(ctx context.Context) *Logger { - if l, ok := ctx.Value(ctxKey{}).(*Logger); ok { - return l - } - return Get() -} - -// WithCtx returns a copy of ctx with the Logger attached. -func WithCtx(ctx context.Context, l *Logger) context.Context { - return context.WithValue(ctx, ctxKey{}, l) -} diff --git a/logger/logger_test.go b/logger/logger_test.go index 977458f..1ee1b02 100644 --- a/logger/logger_test.go +++ b/logger/logger_test.go @@ -158,20 +158,6 @@ func TestResetClosesPreviousWriter(t *testing.T) { assert.NotNil(t, l2.closer) } -func TestWithCtx_FromCtx(t *testing.T) { - l := newLogger(nil) - ctx := WithCtx(context.Background(), l) - - extracted := FromCtx(ctx) - assert.Same(t, l, extracted) -} - -func TestFromCtx_NoLogger(t *testing.T) { - resetGlobal() - l := FromCtx(context.Background()) - assert.NotNil(t, l) // returns the default logger -} - func TestNewLogger_InvalidLevelEnv(t *testing.T) { os.Setenv("FILEGANIZER_LOGGING_LEVEL", "BOGUS") defer os.Unsetenv("FILEGANIZER_LOGGING_LEVEL") diff --git a/main_test.go b/main_test.go index 1104bf0..45e7123 100644 --- a/main_test.go +++ b/main_test.go @@ -15,11 +15,15 @@ import ( "fileganizer/testutil" ) -func TestFileInvoice(t *testing.T) { +func withArgs(t *testing.T, args ...string) { + t.Helper() oldArgs := os.Args - defer func() { os.Args = oldArgs }() // os.Args is a "global variable", so keep the state from before the test, and restore it after. + os.Args = append([]string{"./fileganizer"}, args...) + t.Cleanup(func() { os.Args = oldArgs }) +} - os.Args = []string{"./fileganizer", "-c", "testdata/config.invoice.yaml", "-f", "testdata/invoice.txt"} //nolint:goconst +func TestFileInvoice(t *testing.T) { + withArgs(t, "-c", "testdata/config.invoice.yaml", "-f", "testdata/invoice.txt") output, err := run() assert.Nil(t, err) @@ -27,12 +31,9 @@ func TestFileInvoice(t *testing.T) { } func TestFileInvoiceEnv(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - os.Setenv("SOMEVAR", "magic") defer os.Unsetenv("SOMEVAR") - os.Args = []string{"./fileganizer", "-c", "testdata/config.invoice-env.yaml", "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/config.invoice-env.yaml", "-f", "testdata/invoice.txt") output, err := run() assert.Nil(t, err) @@ -40,10 +41,7 @@ func TestFileInvoiceEnv(t *testing.T) { } func TestBuiltinExtractUnsupportedMIME(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.broken.mime.yaml", "-f", "testdata/minimal.wav"} + withArgs(t, "-c", "testdata/config.broken.mime.yaml", "-f", "testdata/minimal.wav") _, err := run() assert.Error(t, err) @@ -62,10 +60,7 @@ func TestDetectFileType_ReadError(t *testing.T) { } func TestPDFBuiltinExtractor(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.pdfBuiltin.yaml", "-f", "pdftotext/testdata/forged-invoice.pdf"} + withArgs(t, "-c", "testdata/config.pdfBuiltin.yaml", "-f", "pdftotext/testdata/forged-invoice.pdf") output, err := run() assert.Nil(t, err) @@ -73,12 +68,9 @@ func TestPDFBuiltinExtractor(t *testing.T) { } func TestPDFBuiltinExtractorEnv(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - os.Setenv("COMPANY", "ACME Corp") defer os.Unsetenv("COMPANY") - os.Args = []string{"./fileganizer", "-c", "testdata/config.pdfBuiltinEnv.yaml", "-f", "pdftotext/testdata/forged-invoice.pdf"} + withArgs(t, "-c", "testdata/config.pdfBuiltinEnv.yaml", "-f", "pdftotext/testdata/forged-invoice.pdf") output, err := run() assert.Nil(t, err) @@ -86,10 +78,7 @@ func TestPDFBuiltinExtractorEnv(t *testing.T) { } func TestFileNonMatchingPattern(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.invoice-nomatch.yaml", "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/config.invoice-nomatch.yaml", "-f", "testdata/invoice.txt") output, err := run() assert.Nil(t, err) @@ -98,10 +87,7 @@ func TestFileNonMatchingPattern(t *testing.T) { } func TestFileBrokenTemplate(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.broken.template.yaml", "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/config.broken.template.yaml", "-f", "testdata/invoice.txt") output, err := run() assert.Nil(t, err) @@ -109,10 +95,7 @@ func TestFileBrokenTemplate(t *testing.T) { } func TestFileRunMode(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.invoice-run.yaml", "-f", "testdata/invoice.txt", "-r"} + withArgs(t, "-c", "testdata/config.invoice-run.yaml", "-f", "testdata/invoice.txt", "-r") output, err := run() assert.Nil(t, err) @@ -120,10 +103,7 @@ func TestFileRunMode(t *testing.T) { } func TestFileFrenchMonths(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.invoice-french.yaml", "-f", "testdata/invoice-french.txt"} + withArgs(t, "-c", "testdata/config.invoice-french.yaml", "-f", "testdata/invoice-french.txt") output, err := run() assert.Nil(t, err) @@ -131,10 +111,7 @@ func TestFileFrenchMonths(t *testing.T) { } func TestRunMissingConfigFile(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/nonexistent.yaml", "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/nonexistent.yaml", "-f", "testdata/invoice.txt") _, err := run() assert.Error(t, err) @@ -142,20 +119,14 @@ func TestRunMissingConfigFile(t *testing.T) { } func TestRunMissingInputFile(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.invoice.yaml", "-f", "testdata/nonexistent.txt"} + withArgs(t, "-c", "testdata/config.invoice.yaml", "-f", "testdata/nonexistent.txt") _, err := run() assert.Error(t, err) } func TestRunTextOutputFlag(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.invoice.yaml", "-f", "testdata/invoice.txt", "-t"} + withArgs(t, "-c", "testdata/config.invoice.yaml", "-f", "testdata/invoice.txt", "-t") output, err := run() assert.NoError(t, err) @@ -163,40 +134,28 @@ func TestRunTextOutputFlag(t *testing.T) { } func TestRunBrokenGrokPattern(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.broken.grok.yaml", "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/config.broken.grok.yaml", "-f", "testdata/invoice.txt") _, err := run() assert.Error(t, err) } func TestFileRunModeFails(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.broken.run.yaml", "-f", "testdata/invoice.txt", "-r"} + withArgs(t, "-c", "testdata/config.broken.run.yaml", "-f", "testdata/invoice.txt", "-r") _, err := run() assert.Error(t, err) } func TestRunBrokenGrokPatternDefinition(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.broken.regex.yaml", "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/config.broken.regex.yaml", "-f", "testdata/invoice.txt") _, err := run() assert.Error(t, err) } func TestRunVersionFlag(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-V"} + withArgs(t, "-V") _, err := run() assert.NoError(t, err) @@ -217,10 +176,7 @@ func TestBSBStatements(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.bsb.yaml", "-f", tc.pdf} + withArgs(t, "-c", "testdata/config.bsb.yaml", "-f", tc.pdf) output, err := run() assert.Nil(t, err) @@ -230,10 +186,7 @@ func TestBSBStatements(t *testing.T) { } func TestExtractTextMimeNotInConfig(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.broken.mime.yaml", "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/config.broken.mime.yaml", "-f", "testdata/invoice.txt") _, err := run() assert.Error(t, err) @@ -241,10 +194,7 @@ func TestExtractTextMimeNotInConfig(t *testing.T) { } func TestProcessFileDescriptionsNoMatch(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"./fileganizer", "-c", "testdata/config.nomatch.yaml", "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/config.nomatch.yaml", "-f", "testdata/invoice.txt") output, err := run() assert.Nil(t, err) @@ -264,11 +214,7 @@ func TestExtractTextUnsupportedType(t *testing.T) { } func TestFileInvoiceWithCatCommand(t *testing.T) { - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - configFile := "testdata/config.invoice-cat.yaml" - os.Args = []string{"./fileganizer", "-c", configFile, "-f", "testdata/invoice.txt"} + withArgs(t, "-c", "testdata/config.invoice-cat.yaml", "-f", "testdata/invoice.txt") output, err := run() assert.Nil(t, err)