@@ -12,19 +12,22 @@ import {
1212import type { PdfLine } from '@/lib/file-parsers/pdf-lines'
1313
1414const PAGE_HEIGHT = 792
15+ const PITCH = 12
1516
16- function line ( text : string , y : number ) : PdfLine {
17- return { text, y, height : 11 }
17+ function line ( text : string , y : number , height = 11 ) : PdfLine {
18+ return { text, y, height }
1819}
1920
20- /** A page with a top header, a bottom footer, and two body lines. */
21+ /** Body lines at a regular pitch running down from `top`. */
22+ function body ( count : number , top : number , label = 'Body' ) : PdfLine [ ] {
23+ return Array . from ( { length : count } , ( _ , i ) => line ( `${ label } line ${ i + 1 } ` , top - i * PITCH ) )
24+ }
25+
26+ /** A page with a top header, a bottom footer, and a body separated from both by a margin gap. */
2127function page ( index : number , options : { header ?: string ; footer ?: string } = { } ) : PdfPageLines {
2228 const lines : PdfLine [ ] = [ ]
2329 if ( options . header ) lines . push ( line ( options . header , 729 ) )
24- lines . push (
25- line ( `Body line one of page ${ index } ` , 600 ) ,
26- line ( `Body line two of page ${ index } ` , 585 )
27- )
30+ lines . push ( ...body ( 40 , 660 , `Page ${ index } ` ) )
2831 if ( options . footer ) lines . push ( line ( options . footer , 55 ) )
2932 return { lines, pageHeight : PAGE_HEIGHT }
3033}
@@ -33,25 +36,6 @@ function texts(pages: PdfLine[][]): string[][] {
3336 return pages . map ( ( lines ) => lines . map ( ( entry ) => entry . text ) )
3437}
3538
36- describe ( 'page numbers outside the band' , ( ) => {
37- it ( 'drops a folio that is the last line of a page even inside a wide margin' , ( ) => {
38- const page = ( n : number ) => ( {
39- pageHeight : 842 ,
40- lines : [
41- { text : 'Body text of the page.' , y : 700 , height : 10 } ,
42- { text : String ( n ) , y : 189 , height : 10 } ,
43- ] ,
44- } )
45- const result = suppressFurniture ( [ page ( 1 ) , page ( 2 ) , page ( 3 ) ] )
46-
47- expect ( result . map ( ( lines ) => lines . map ( ( line ) => line . text ) ) ) . toEqual ( [
48- [ 'Body text of the page.' ] ,
49- [ 'Body text of the page.' ] ,
50- [ 'Body text of the page.' ] ,
51- ] )
52- } )
53- } )
54-
5539describe ( 'suppressFurniture' , ( ) => {
5640 it ( 'drops a header repeated on enough pages but keeps its first occurrence' , ( ) => {
5741 const pages = [ 1 , 2 , 3 , 4 ] . map ( ( i ) => page ( i , { header : 'ACME Corp — Internal Use Only' } ) )
@@ -61,7 +45,7 @@ describe('suppressFurniture', () => {
6145 expect ( result [ 0 ] ) . toContain ( 'ACME Corp — Internal Use Only' )
6246 for ( const remaining of result . slice ( 1 ) ) {
6347 expect ( remaining ) . not . toContain ( 'ACME Corp — Internal Use Only' )
64- expect ( remaining ) . toHaveLength ( 2 )
48+ expect ( remaining ) . toHaveLength ( 40 )
6549 }
6650 } )
6751
@@ -114,12 +98,30 @@ describe('suppressFurniture', () => {
11498
11599 it ( 'drops page numbers in the bands regardless of repetition' , ( ) => {
116100 const pages : PdfPageLines [ ] = [
117- { lines : [ line ( 'Page 1 of 3' , 55 ) , line ( 'Body' , 600 ) ] , pageHeight : PAGE_HEIGHT } ,
118- { lines : [ line ( '2' , 55 ) , line ( 'Body' , 600 ) , line ( 'iv ' , 729 ) ] , pageHeight : PAGE_HEIGHT } ,
119- { lines : [ line ( '- 3 -' , 55 ) , line ( 'Body' , 600 ) , line ( '925' , 60 ) ] , pageHeight : PAGE_HEIGHT } ,
101+ { lines : [ line ( 'Page 1 of 3' , 55 ) , ... body ( 3 , 600 ) ] , pageHeight : PAGE_HEIGHT } ,
102+ { lines : [ line ( '2' , 55 ) , ... body ( 3 , 600 ) , line ( 'ii ' , 729 ) ] , pageHeight : PAGE_HEIGHT } ,
103+ { lines : [ line ( '- 3 -' , 55 ) , ... body ( 3 , 600 ) , line ( '925' , 60 ) ] , pageHeight : PAGE_HEIGHT } ,
120104 ]
121105
122- expect ( texts ( suppressFurniture ( pages ) ) ) . toEqual ( [ [ 'Body' ] , [ 'Body' ] , [ 'Body' , '925' ] ] )
106+ const result = texts ( suppressFurniture ( pages ) )
107+
108+ expect ( result [ 0 ] ) . toEqual ( [ 'Body line 1' , 'Body line 2' , 'Body line 3' ] )
109+ expect ( result [ 1 ] ) . toEqual ( [ 'Body line 1' , 'Body line 2' , 'Body line 3' ] )
110+ expect ( result [ 2 ] ) . toEqual ( [ 'Body line 1' , 'Body line 2' , 'Body line 3' , '925' ] )
111+ } )
112+
113+ it ( 'drops a folio outside the band only when a margin gap separates it from the text' , ( ) => {
114+ const pages = [ 1 , 2 ] . map ( ( i ) => ( {
115+ lines : [ ...body ( 20 , 700 ) , line ( `${ i } ` , 100 ) ] ,
116+ pageHeight : PAGE_HEIGHT ,
117+ } ) )
118+ const tableCells = [ 1 , 2 ] . map ( ( ) => ( {
119+ lines : [ ...body ( 50 , 700 ) , line ( '1' , 700 - 50 * PITCH ) ] ,
120+ pageHeight : PAGE_HEIGHT ,
121+ } ) )
122+
123+ for ( const kept of texts ( suppressFurniture ( pages ) ) ) expect ( kept ) . toHaveLength ( 20 )
124+ for ( const kept of texts ( suppressFurniture ( tableCells ) ) ) expect ( kept ) . toContain ( '1' )
123125 } )
124126
125127 it ( 'ignores band text longer than the furniture cap, such as a repeated table header' , ( ) => {
@@ -135,7 +137,7 @@ describe('suppressFurniture', () => {
135137 it ( 'merges same-baseline fragments into one key before matching' , ( ) => {
136138 const pages = [ 1 , 2 , 3 , 4 ] . map ( ( i ) => ( {
137139 lines : [
138- line ( 'Body' , 600 ) ,
140+ ... body ( 3 , 600 ) ,
139141 line ( `${ i } ` , 31.3 ) ,
140142 line ( 'Chapter 1' , 31.3 ) ,
141143 line ( 'Publication 17 (2025)' , 32.5 ) ,
@@ -145,8 +147,83 @@ describe('suppressFurniture', () => {
145147
146148 const result = texts ( suppressFurniture ( pages ) )
147149
148- expect ( result [ 0 ] ) . toEqual ( [ 'Body' , '1' , 'Chapter 1' , 'Publication 17 (2025)' ] )
149- expect ( result [ 3 ] ) . toEqual ( [ 'Body' ] )
150+ expect ( result [ 0 ] . slice ( 3 ) ) . toEqual ( [ '1' , 'Chapter 1' , 'Publication 17 (2025)' ] )
151+ expect ( result [ 3 ] ) . toHaveLength ( 3 )
152+ } )
153+
154+ it ( 'keeps a table header that repeats at the top of every page while dropping the running header above it' , ( ) => {
155+ const pages = Array . from ( { length : 5 } , ( ) => {
156+ const rows = Array . from ( { length : 50 } , ( _ , i ) =>
157+ line ( `${ 1000 + i } ${ 2000 + i } ${ 3000 + i } ` , 740 - ( i + 1 ) * 8 , 7.5 )
158+ )
159+ return {
160+ lines : [
161+ line ( '2025 Tax Table — Continued' , 772 , 10 ) ,
162+ line ( 'Single Married filing jointly Head of household' , 740 , 7.5 ) ,
163+ ...rows ,
164+ line ( 'Need more information? Visit IRS.gov.' , 31 , 10 ) ,
165+ ] ,
166+ pageHeight : PAGE_HEIGHT ,
167+ }
168+ } )
169+
170+ const result = texts ( suppressFurniture ( pages ) )
171+
172+ for ( const kept of result ) {
173+ expect ( kept ) . toContain ( 'Single Married filing jointly Head of household' )
174+ }
175+ expect ( result . flat ( ) . filter ( ( text ) => text === '2025 Tax Table — Continued' ) ) . toHaveLength ( 1 )
176+ expect (
177+ result . flat ( ) . filter ( ( text ) => text === 'Need more information? Visit IRS.gov.' )
178+ ) . toHaveLength ( 1 )
179+ } )
180+
181+ it ( 'keeps a footnote that sits directly under the body while dropping the footer below it' , ( ) => {
182+ const pages = Array . from ( { length : 4 } , ( ) => ( {
183+ lines : [
184+ ...body ( 52 , 700 ) ,
185+ line (
186+ '* This column must also be used by a qualifying surviving spouse.' ,
187+ 700 - 52 * PITCH ,
188+ 8
189+ ) ,
190+ line ( 'Visit IRS.gov.' , 30 , 10 ) ,
191+ ] ,
192+ pageHeight : PAGE_HEIGHT ,
193+ } ) )
194+
195+ const result = texts ( suppressFurniture ( pages ) )
196+
197+ for ( const kept of result ) {
198+ expect ( kept ) . toContain ( '* This column must also be used by a qualifying surviving spouse.' )
199+ }
200+ expect ( result . flat ( ) . filter ( ( text ) => text === 'Visit IRS.gov.' ) ) . toHaveLength ( 1 )
201+ } )
202+
203+ it ( 'keeps a band line whose text also occurs in body positions' , ( ) => {
204+ const pages = [ 1 , 2 , 3 , 4 ] . map ( ( i ) => ( {
205+ lines : [
206+ line ( 'Quarter Revenue Margin' , 760 ) ,
207+ ...body ( 40 , 660 ) ,
208+ ...( i === 1 ? [ line ( 'Quarter Revenue Margin' , 400 ) ] : [ ] ) ,
209+ ] ,
210+ pageHeight : PAGE_HEIGHT ,
211+ } ) )
212+
213+ for ( const kept of texts ( suppressFurniture ( pages ) ) ) {
214+ expect ( kept ) . toContain ( 'Quarter Revenue Margin' )
215+ }
216+ } )
217+
218+ it ( 'never drops a band line that would leave a hyphenated word orphaned' , ( ) => {
219+ const pages = [ 1 , 2 , 3 , 4 ] . map ( ( ) => ( {
220+ lines : [ line ( 'Married filing sepa-' , 760 ) , ...body ( 40 , 660 ) ] ,
221+ pageHeight : PAGE_HEIGHT ,
222+ } ) )
223+
224+ for ( const kept of texts ( suppressFurniture ( pages ) ) ) {
225+ expect ( kept ) . toContain ( 'Married filing sepa-' )
226+ }
150227 } )
151228
152229 it ( 'leaves body text alone even when it repeats' , ( ) => {
@@ -189,7 +266,8 @@ describe('isPageNumber', () => {
189266 expect ( isPageNumber ( 'page 3 of 10' , 10 ) ) . toBe ( true )
190267 expect ( isPageNumber ( '3 / 10' , 10 ) ) . toBe ( true )
191268 expect ( isPageNumber ( '7' , 10 ) ) . toBe ( true )
192- expect ( isPageNumber ( 'xiv' , 10 ) ) . toBe ( true )
269+ expect ( isPageNumber ( 'xiv' , 20 ) ) . toBe ( true )
270+ expect ( isPageNumber ( 'CD' , 500 ) ) . toBe ( true )
193271 expect ( isPageNumber ( '— 12 —' , 20 ) ) . toBe ( true )
194272 } )
195273
@@ -199,6 +277,15 @@ describe('isPageNumber', () => {
199277 expect ( isPageNumber ( 'Chapter 1' , 10 ) ) . toBe ( false )
200278 expect ( isPageNumber ( 'civilian' , 10 ) ) . toBe ( false )
201279 } )
280+
281+ it ( 'rejects words that merely look like roman numerals' , ( ) => {
282+ for ( const word of [ 'mix' , 'mild' , 'civil' , 'vivid' , 'mimic' , 'dim' ] ) {
283+ expect ( isPageNumber ( word , 1000 ) ) . toBe ( false )
284+ }
285+ expect ( isPageNumber ( 'CD' , 10 ) ) . toBe ( false )
286+ expect ( isPageNumber ( 'xiv' , 10 ) ) . toBe ( false )
287+ expect ( isPageNumber ( 'ii' , 1 ) ) . toBe ( false )
288+ } )
202289} )
203290
204291describe ( 'furnitureThreshold' , ( ) => {
0 commit comments