11import JSZip from 'jszip'
22import { FileParserError } from '@/lib/file-parsers/errors'
33import {
4+ assertTextWithinLimit ,
5+ chargeTextBudget ,
46 childElements ,
57 collapseWhitespace ,
68 findFirst ,
@@ -13,6 +15,8 @@ import {
1315 readXmlPart ,
1416 TABLE_CLOSE ,
1517 TABLE_OPEN ,
18+ type TextBudget ,
19+ trimLineEnds ,
1620 type XmlElement ,
1721} from '@/lib/file-parsers/office-text'
1822import type { FileParseOptions } from '@/lib/file-parsers/types'
@@ -49,6 +53,9 @@ const SKIPPED_PRESENTATION_CLASSES = new Set(['header', 'footer', 'date-time', '
4953/** Bounds `table:number-columns-repeated`, which spreadsheets inflate to 1024. */
5054const MAX_REPEATED_COLUMNS = 32
5155
56+ /** Bounds one `text:s` run; `text:c` is attacker-controlled and would otherwise size an allocation. */
57+ const MAX_SPACE_RUN = 100
58+
5259const MAX_LIST_INDENT = 3
5360
5461interface WalkState {
@@ -57,10 +64,18 @@ interface WalkState {
5764 pendingNotes : string [ ]
5865 /** Inside a table cell, nested tables flatten to text rather than emitting markers. */
5966 inCell : boolean
67+ /** Shared across the document so cells and note bodies count toward one ceiling. */
68+ budget : TextBudget
69+ }
70+
71+ function newState ( budget : TextBudget , inCell = false ) : WalkState {
72+ return { blocks : [ ] , pendingNotes : [ ] , inCell, budget }
6073}
6174
62- function newState ( inCell = false ) : WalkState {
63- return { blocks : [ ] , pendingNotes : [ ] , inCell }
75+ /** Records a piece of emitted text against the document ceiling before it is kept. */
76+ function emit ( state : WalkState , pieces : string [ ] , text : string ) : void {
77+ chargeTextBudget ( state . budget , text . length )
78+ pieces . push ( text )
6479}
6580
6681function isSkipped ( element : XmlElement ) : boolean {
@@ -80,22 +95,23 @@ function inlineText(element: XmlElement, state: WalkState): string {
8095 const pieces : string [ ] = [ ]
8196 for ( const child of element . children ) {
8297 if ( child . type === 'text' ) {
83- pieces . push ( child . data )
98+ emit ( state , pieces , child . data )
8499 continue
85100 }
86101 if ( ! isXmlElement ( child ) || isSkipped ( child ) ) continue
87102
88103 switch ( child . name ) {
89104 case 'text:s' : {
90105 const count = Number . parseInt ( child . attribs [ 'text:c' ] ?? '1' , 10 )
91- pieces . push ( ' ' . repeat ( Number . isFinite ( count ) && count > 0 ? count : 1 ) )
106+ const bounded = Number . isFinite ( count ) && count > 0 ? Math . min ( count , MAX_SPACE_RUN ) : 1
107+ emit ( state , pieces , ' ' . repeat ( bounded ) )
92108 break
93109 }
94110 case 'text:tab' :
95- pieces . push ( '\t' )
111+ emit ( state , pieces , '\t' )
96112 break
97113 case 'text:line-break' :
98- pieces . push ( '\n' )
114+ emit ( state , pieces , '\n' )
99115 break
100116 case 'draw:frame' : {
101117 const image = imageFrameText ( child , state )
@@ -106,7 +122,7 @@ function inlineText(element: XmlElement, state: WalkState): string {
106122 const citation = findFirst ( child , 'text:note-citation' )
107123 const body = findFirst ( child , 'text:note-body' )
108124 const label = citation ? collapseWhitespace ( inlineText ( citation , state ) ) : ''
109- const bodyText = body ? collapseWhitespace ( blockText ( body ) ) : ''
125+ const bodyText = body ? collapseWhitespace ( blockText ( body , state . budget ) ) : ''
110126 if ( label ) pieces . push ( `[${ label } ]` )
111127 if ( bodyText ) state . pendingNotes . push ( label ? `[${ label } ] ${ bodyText } ` : bodyText )
112128 break
@@ -119,8 +135,8 @@ function inlineText(element: XmlElement, state: WalkState): string {
119135}
120136
121137/** Renders a container's block children to a single string, for cells and note bodies. */
122- function blockText ( container : XmlElement , inCell = false ) : string {
123- const state = newState ( inCell )
138+ function blockText ( container : XmlElement , budget : TextBudget , inCell = false ) : string {
139+ const state = newState ( budget , inCell )
124140 walkChildren ( container , state , 0 )
125141 return [ ...state . blocks , ...state . pendingNotes ] . join ( '\n' )
126142}
@@ -132,9 +148,7 @@ function flushNotes(state: WalkState): void {
132148}
133149
134150function emitParagraph ( element : XmlElement , state : WalkState , heading : boolean ) : void {
135- const text = inlineText ( element , state )
136- . replace ( / [ \t ] + \n / g, '\n' )
137- . trim ( )
151+ const text = trimLineEnds ( inlineText ( element , state ) ) . trim ( )
138152 if ( text ) {
139153 state . blocks . push ( heading ? `\n${ text } \n` : text )
140154 }
@@ -167,8 +181,8 @@ function emitList(list: XmlElement, state: WalkState, depth: number): void {
167181 }
168182}
169183
170- function cellText ( cell : XmlElement ) : string {
171- return collapseWhitespace ( blockText ( cell , true ) )
184+ function cellText ( cell : XmlElement , budget : TextBudget ) : string {
185+ return collapseWhitespace ( blockText ( cell , budget , true ) )
172186}
173187
174188function repeatCount ( element : XmlElement , attribute : string , cap : number ) : number {
@@ -179,15 +193,15 @@ function repeatCount(element: XmlElement, attribute: string, cap: number): numbe
179193 return Math . min ( parsed , cap )
180194}
181195
182- function tableRows ( container : XmlElement , rows : string [ ] ) : void {
196+ function tableRows ( container : XmlElement , rows : string [ ] , state : WalkState ) : void {
183197 for ( const child of childElements ( container ) ) {
184198 if ( isSkipped ( child ) ) continue
185199 switch ( child . name ) {
186200 case 'table:table-row' : {
187201 const cells : string [ ] = [ ]
188202 for ( const cell of childElements ( child ) ) {
189203 if ( cell . name !== 'table:table-cell' && cell . name !== 'table:covered-table-cell' ) continue
190- const text = cellText ( cell )
204+ const text = cellText ( cell , state . budget )
191205 const repeats = repeatCount ( cell , 'table:number-columns-repeated' , MAX_REPEATED_COLUMNS )
192206 for ( let i = 0 ; i < repeats ; i ++ ) cells . push ( text )
193207 }
@@ -197,7 +211,7 @@ function tableRows(container: XmlElement, rows: string[]): void {
197211 case 'table:table-header-rows' :
198212 case 'table:table-rows' :
199213 case 'table:table-row-group' :
200- tableRows ( child , rows )
214+ tableRows ( child , rows , state )
201215 break
202216 default :
203217 break
@@ -206,34 +220,34 @@ function tableRows(container: XmlElement, rows: string[]): void {
206220}
207221
208222/** Every non-empty cell of a table in reading order, for a table nested inside a cell. */
209- function flattenedCells ( container : XmlElement , cells : string [ ] ) : void {
223+ function flattenedCells ( container : XmlElement , cells : string [ ] , state : WalkState ) : void {
210224 for ( const child of childElements ( container ) ) {
211225 if ( isSkipped ( child ) ) continue
212226 if ( child . name === 'table:table-row' ) {
213227 for ( const cell of childElements ( child ) ) {
214228 if ( cell . name !== 'table:table-cell' && cell . name !== 'table:covered-table-cell' ) continue
215- const text = cellText ( cell )
229+ const text = cellText ( cell , state . budget )
216230 if ( text ) cells . push ( text )
217231 }
218232 } else if (
219233 child . name === 'table:table-header-rows' ||
220234 child . name === 'table:table-rows' ||
221235 child . name === 'table:table-row-group'
222236 ) {
223- flattenedCells ( child , cells )
237+ flattenedCells ( child , cells , state )
224238 }
225239 }
226240}
227241
228242function emitTable ( table : XmlElement , state : WalkState ) : void {
229243 if ( state . inCell ) {
230244 const cells : string [ ] = [ ]
231- flattenedCells ( table , cells )
245+ flattenedCells ( table , cells , state )
232246 if ( cells . length > 0 ) state . blocks . push ( cells . join ( ' / ' ) )
233247 return
234248 }
235249 const rows : string [ ] = [ ]
236- tableRows ( table , rows )
250+ tableRows ( table , rows , state )
237251 if ( rows . length > 0 ) {
238252 state . blocks . push ( '' , TABLE_OPEN , ...rows , TABLE_CLOSE , '' )
239253 }
@@ -251,7 +265,7 @@ function imageFrameText(frame: XmlElement, state: WalkState): string | null {
251265}
252266
253267function emitNotes ( notes : XmlElement , state : WalkState ) : void {
254- const body = collapseWhitespace ( blockText ( notes ) )
268+ const body = collapseWhitespace ( blockText ( notes , state . budget ) )
255269 if ( body ) state . blocks . push ( NOTES_MARKER , body )
256270}
257271
@@ -299,11 +313,11 @@ function walkChildren(container: XmlElement, state: WalkState, depth: number): v
299313 }
300314}
301315
302- function contentBlocks ( contentXml : string ) : string [ ] {
316+ function contentBlocks ( contentXml : string , budget : TextBudget ) : string [ ] {
303317 const document = parseXml ( contentXml )
304318 const body = findFirst ( document , 'office:body' )
305319 if ( ! body ) return [ ]
306- const state = newState ( )
320+ const state = newState ( budget )
307321 walkChildren ( body , state , 0 )
308322 flushNotes ( state )
309323 return state . blocks
@@ -321,7 +335,8 @@ function embeddedContentParts(zip: JSZip): string[] {
321335/**
322336 * Extracts structured text from an OpenDocument text or presentation package.
323337 * The caller must already have applied the archive size guard; each XML part is
324- * additionally bounded by {@link readXmlPart}. An archive without `content.xml`
338+ * additionally bounded by {@link readXmlPart} and the assembled text by
339+ * {@link assertTextWithinLimit}. An archive without `content.xml`
325340 * is not an OpenDocument file at all and is rejected as `invalid_format`; a
326341 * present but textless body yields an empty string for the caller to classify.
327342 */
@@ -339,14 +354,15 @@ export async function extractOpenDocumentText(
339354 )
340355 }
341356
357+ const budget : TextBudget = { used : 0 }
342358 const sections : string [ ] = [ ]
343359 for ( const path of [ CONTENT_PART , ...embeddedContentParts ( zip ) ] ) {
344360 const xml = await readXmlPart ( zip , path )
345361 options . signal ?. throwIfAborted ( )
346362 if ( xml === null ) continue
347- const blocks = contentBlocks ( xml )
363+ const blocks = contentBlocks ( xml , budget )
348364 if ( blocks . length > 0 ) sections . push ( joinBlocks ( blocks ) , '' )
349365 }
350366
351- return joinBlocks ( sections )
367+ return assertTextWithinLimit ( joinBlocks ( sections ) )
352368}
0 commit comments