3333 _build_mesh_shading , _build_patch_shading )
3434
3535
36+ def _merge_bboxes (bboxes : list [tuple | None ]) -> tuple | None :
37+ """Compute the union of multiple bounding boxes.
38+
39+ Each bbox is (x0, y0, x1, y1) or None. Returns None if all are None.
40+ """
41+ xmin = ymin = float ('inf' )
42+ xmax = ymax = float ('-inf' )
43+ any_valid = False
44+ for bbox in bboxes :
45+ if bbox is not None :
46+ any_valid = True
47+ xmin = min (xmin , bbox [0 ])
48+ ymin = min (ymin , bbox [1 ])
49+ xmax = max (xmax , bbox [2 ])
50+ ymax = max (ymax , bbox [3 ])
51+ if not any_valid :
52+ return None
53+ return (xmin , ymin , xmax , ymax )
54+
55+
3656def generate_content_stream (display_list : ps .DisplayList ,
3757 height_device : float ,
3858 font_tracker : FontTracker ,
@@ -95,6 +115,12 @@ def generate_content_stream(display_list: ps.DisplayList,
95115 # Pending Type 3 char codes for ActualText -> ToUnicode correlation
96116 type3_pending_codes : list [tuple ] = [] # (char_code, font_key)
97117
118+ # Mesh/patch shading batching: consecutive same-CTM items combined
119+ mesh_batch : list [ps .MeshShadingFill ] = []
120+ mesh_batch_ctm : tuple | None = None
121+ patch_batch : list [ps .PatchShadingFill ] = []
122+ patch_batch_ctm : tuple | None = None
123+
98124 def _flush_text_batch () -> None :
99125 nonlocal text_batch , text_batch_font
100126 if text_batch :
@@ -121,6 +147,46 @@ def _flush_type3_text() -> None:
121147 type3_batch_color = None
122148 type3_pending_codes = []
123149
150+ def _flush_mesh_batch () -> None :
151+ nonlocal mesh_batch , mesh_batch_ctm , shading_counter
152+ if not mesh_batch :
153+ return
154+ # Combine all triangles into one flat list
155+ combined : list = []
156+ for item in mesh_batch :
157+ combined .extend (item .triangles )
158+ # Merge bounding boxes (union of all non-None bboxes)
159+ merged_bbox = _merge_bboxes ([item .bbox for item in mesh_batch ])
160+ synthetic = ps .MeshShadingFill (combined , mesh_batch_ctm , merged_bbox )
161+ sh_desc = _build_mesh_shading (synthetic )
162+ if sh_desc is not None :
163+ sh_name = f'/Sh{ shading_counter } '
164+ shading_counter += 1
165+ shading_defs .append ((sh_name , sh_desc ))
166+ _emit_shading_ref (lines , sh_name , mesh_batch_ctm )
167+ mesh_batch = []
168+ mesh_batch_ctm = None
169+
170+ def _flush_patch_batch () -> None :
171+ nonlocal patch_batch , patch_batch_ctm , shading_counter
172+ if not patch_batch :
173+ return
174+ # Combine all patches into one flat list
175+ combined : list = []
176+ for item in patch_batch :
177+ combined .extend (item .patches )
178+ # Merge bounding boxes
179+ merged_bbox = _merge_bboxes ([item .bbox for item in patch_batch ])
180+ synthetic = ps .PatchShadingFill (combined , patch_batch_ctm , merged_bbox )
181+ sh_desc = _build_patch_shading (synthetic )
182+ if sh_desc is not None :
183+ sh_name = f'/Sh{ shading_counter } '
184+ shading_counter += 1
185+ shading_defs .append ((sh_name , sh_desc ))
186+ _emit_shading_ref (lines , sh_name , patch_batch_ctm )
187+ patch_batch = []
188+ patch_batch_ctm = None
189+
124190 # The display list is in device space (Y=0 at top, Y increases downward)
125191 # but PDF uses Y=0 at bottom, Y increases upward. Apply a combined
126192 # device-to-points scale + Y-flip transform at the start.
@@ -136,6 +202,8 @@ def _flush_type3_text() -> None:
136202 _flush_type3_text ()
137203 _flush_text_batch ()
138204 _flush_invis_batch ()
205+ _flush_mesh_batch ()
206+ _flush_patch_batch ()
139207 type3_suppress_invis = False
140208 current_path = item
141209 current_path_lines = _emit_path (item )
@@ -148,6 +216,8 @@ def _flush_type3_text() -> None:
148216 _flush_text_batch ()
149217 _flush_invis_batch ()
150218 _close_aniso_batch (lines , gs )
219+ _flush_mesh_batch ()
220+ _flush_patch_batch ()
151221 type3_suppress_invis = False
152222 _emit_fill (lines , current_path_lines , item , gs )
153223 current_path = None
@@ -161,6 +231,8 @@ def _flush_type3_text() -> None:
161231 _flush_type3_text ()
162232 _flush_text_batch ()
163233 _flush_invis_batch ()
234+ _flush_mesh_batch ()
235+ _flush_patch_batch ()
164236 type3_suppress_invis = False
165237 _emit_stroke (lines , current_path_lines , current_path , item , gs )
166238 current_path = None
@@ -171,6 +243,8 @@ def _flush_type3_text() -> None:
171243 _flush_text_batch ()
172244 _flush_invis_batch ()
173245 _close_aniso_batch (lines , gs )
246+ _flush_mesh_batch ()
247+ _flush_patch_batch ()
174248 type3_suppress_invis = False
175249
176250 if item .is_initclip :
@@ -198,6 +272,8 @@ def _flush_type3_text() -> None:
198272 _flush_type3_text ()
199273 _flush_invis_batch ()
200274 _close_aniso_batch (lines , gs )
275+ _flush_mesh_batch ()
276+ _flush_patch_batch ()
201277 type3_suppress_invis = False
202278 # Batch consecutive same-font TextObjs into one BT/ET
203279 font_name = _resolve_text_font (item , font_tracker , embedded_fonts )
@@ -217,6 +293,8 @@ def _flush_type3_text() -> None:
217293 _flush_text_batch ()
218294 _flush_invis_batch ()
219295 _close_aniso_batch (lines , gs )
296+ _flush_mesh_batch ()
297+ _flush_patch_batch ()
220298 type3_suppress_invis = False
221299 img_name , image_counter = _emit_image_xobject (
222300 lines , item , image_defs , image_counter , gs )
@@ -226,6 +304,8 @@ def _flush_type3_text() -> None:
226304 _flush_text_batch ()
227305 _flush_invis_batch ()
228306 _close_aniso_batch (lines , gs )
307+ _flush_mesh_batch ()
308+ _flush_patch_batch ()
229309 type3_suppress_invis = False
230310 if isinstance (item , ps .AxialShadingFill ):
231311 sh_desc = _build_axial_shading (item )
@@ -242,6 +322,8 @@ def _flush_type3_text() -> None:
242322 _flush_text_batch ()
243323 _flush_invis_batch ()
244324 _close_aniso_batch (lines , gs )
325+ _flush_mesh_batch ()
326+ _flush_patch_batch ()
245327 type3_suppress_invis = False
246328 _emit_function_shading (lines , item )
247329
@@ -250,26 +332,28 @@ def _flush_type3_text() -> None:
250332 _flush_text_batch ()
251333 _flush_invis_batch ()
252334 _close_aniso_batch (lines , gs )
335+ _flush_patch_batch ()
253336 type3_suppress_invis = False
254- sh_desc = _build_mesh_shading ( item )
255- if sh_desc is not None :
256- sh_name = f'/Sh { shading_counter } '
257- shading_counter += 1
258- shading_defs . append (( sh_name , sh_desc ))
259- _emit_shading_ref ( lines , sh_name , item .ctm )
337+ if mesh_batch and item . ctm == mesh_batch_ctm :
338+ mesh_batch . append ( item )
339+ else :
340+ _flush_mesh_batch ()
341+ mesh_batch = [ item ]
342+ mesh_batch_ctm = item .ctm
260343
261344 elif isinstance (item , ps .PatchShadingFill ):
262345 _flush_type3_text ()
263346 _flush_text_batch ()
264347 _flush_invis_batch ()
265348 _close_aniso_batch (lines , gs )
349+ _flush_mesh_batch ()
266350 type3_suppress_invis = False
267- sh_desc = _build_patch_shading ( item )
268- if sh_desc is not None :
269- sh_name = f'/Sh { shading_counter } '
270- shading_counter += 1
271- shading_defs . append (( sh_name , sh_desc ))
272- _emit_shading_ref ( lines , sh_name , item .ctm )
351+ if patch_batch and item . ctm == patch_batch_ctm :
352+ patch_batch . append ( item )
353+ else :
354+ _flush_patch_batch ()
355+ patch_batch = [ item ]
356+ patch_batch_ctm = item .ctm
273357
274358 elif isinstance (item , ps .ErasePage ):
275359 pass # No-op in PDF
@@ -282,6 +366,8 @@ def _flush_type3_text() -> None:
282366 _flush_text_batch ()
283367 _flush_invis_batch ()
284368 _close_aniso_batch (lines , gs )
369+ _flush_mesh_batch ()
370+ _flush_patch_batch ()
285371 type3_suppress_invis = False
286372 # PatternFill is not yet supported
287373 current_path = None
@@ -290,6 +376,8 @@ def _flush_type3_text() -> None:
290376 elif isinstance (item , ps .GlyphRef ):
291377 _flush_text_batch ()
292378 _close_aniso_batch (lines , gs )
379+ _flush_mesh_batch ()
380+ _flush_patch_batch ()
293381
294382 # Try to emit as Type 3 font reference
295383 path_cache = global_resources .get_glyph_cache ()
@@ -457,6 +545,10 @@ def _flush_type3_text() -> None:
457545 # Close any open anisotropic stroke batch
458546 _close_aniso_batch (lines , gs )
459547
548+ # Flush any pending mesh/patch shading batches
549+ _flush_mesh_batch ()
550+ _flush_patch_batch ()
551+
460552 # Close any remaining clip groups
461553 while clip_depth > 0 :
462554 lines .append (b'Q' )
0 commit comments