Skip to content

Commit 7461098

Browse files
committed
Coalesce consecutive mesh and patch shadings in PDF output
Batch consecutive same-CTM MeshShadingFill and PatchShadingFill display list items into single combined PDF shading objects. This produces smaller PDFs with higher-quality rendering by letting the PDF viewer's native mesh renderer handle the full surface instead of thousands of individual single-triangle shading objects.
1 parent 5f34233 commit 7461098

1 file changed

Lines changed: 104 additions & 12 deletions

File tree

postforge/devices/pdf/content_stream.py

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@
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+
3656
def 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

Comments
 (0)