pi-quiver 5.4.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """doc_to_md child. argv[1] = mode (info | pdf-primary | pdf-fallback | xlsx); options JSON on stdin;
2
+ """doc_to_md child. argv[1] = mode (info | pdf-primary | pdf-fallback | xlsx | render-pages); options JSON on stdin;
3
3
  one JSON result on stdout. Exit 0 ok, 1 conversion failure (traceback on stderr), 3 user error
4
4
  ({"error", "pageCount"} on stdout). Library chatter is redirected to stderr so stdout is the result only.
5
5
  Imports `pymupdf` / `pymupdf4llm` (never the deprecated `fitz` alias)."""
@@ -21,6 +21,12 @@ HTML_IMAGE_RE = re.compile(
21
21
  )
22
22
 
23
23
 
24
+ def image_source_map(source, target, filename):
25
+ real = os.path.realpath(source)
26
+ return {source: target, source.replace("\\", "/"): target,
27
+ real: target, real.replace("\\", "/"): target, filename: target}
28
+
29
+
24
30
  def rewrite_image_destinations(md, sources):
25
31
  def markdown(match):
26
32
  dest = match.group(2) if match.group(2) is not None else match.group(3)
@@ -106,7 +112,7 @@ def mode_pdf_primary(o):
106
112
  dest = f"img{i}{os.path.splitext(f)[1].lower()}"
107
113
  source = os.path.join(tmp, f)
108
114
  target = f"p{n}/{dest}"
109
- sources.update({source: target, os.path.realpath(source): target, f: target})
115
+ sources.update(image_source_map(source, target, f))
110
116
  os.replace(source, os.path.join(d, dest))
111
117
  md = rewrite_image_destinations(md, sources)
112
118
  if not md.strip():
@@ -186,27 +192,184 @@ def col_letter(i):
186
192
  return get_column_letter(i)
187
193
 
188
194
 
195
+ PREVIEW_ROWS, PREVIEW_COLS = 100, 50
196
+ PROFILE_MAJORITY, DISTINCT_CAP, SLUG_MAX = 0.6, 50, 40
197
+ MAX_RENDER_PX, MIN_RENDER_DPI, MIN_PAGE_PT = 16_000_000, 36, 72
198
+ INV_HEADER = "| # | name | kind | size | hidden | charts | images | rendered | data |\n|---|---|---|---|---|---|---|---|---|\n"
199
+ XLS_NOTE = "Rendered views: unavailable (visual detection not supported for .xls)"
200
+
201
+
202
+ def slug(name):
203
+ s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:SLUG_MAX].strip("-")
204
+ return s or "sheet"
205
+
206
+
207
+ def csv_value(v):
208
+ import datetime
209
+ if v is None:
210
+ return ""
211
+ if isinstance(v, bool):
212
+ return "TRUE" if v else "FALSE"
213
+ if isinstance(v, (datetime.date, datetime.datetime)):
214
+ return v.isoformat()
215
+ return str(v)
216
+
217
+
218
+ def cell_class(v):
219
+ import datetime
220
+ if isinstance(v, bool):
221
+ return "bool"
222
+ if isinstance(v, int):
223
+ return "int"
224
+ if isinstance(v, float):
225
+ return "float"
226
+ if isinstance(v, (datetime.date, datetime.datetime)):
227
+ return "date"
228
+ return "str"
229
+
230
+
231
+ def is_formula(src):
232
+ return isinstance(src, str) and src.startswith("=")
233
+
234
+
235
+ class ColProfile:
236
+ def __init__(self):
237
+ self.n, self.classes, self.lo, self.hi, self.distinct = 0, {}, None, None, set()
238
+
239
+ def add(self, cls, v):
240
+ self.n += 1
241
+ self.classes[cls] = self.classes.get(cls, 0) + 1
242
+ if cls in ("int", "float", "date"):
243
+ try:
244
+ if self.lo is None or v < self.lo:
245
+ self.lo = v
246
+ if self.hi is None or v > self.hi:
247
+ self.hi = v
248
+ except TypeError:
249
+ pass
250
+ if len(self.distinct) <= DISTINCT_CAP:
251
+ self.distinct.add(repr(v))
252
+
253
+ def row(self, letter, header):
254
+ if not self.n:
255
+ return f"| {letter} | {header} | - | 0 | - | - | 0 |"
256
+ cls, cnt = max(self.classes.items(), key=lambda kv: kv[1])
257
+ typ = cls if cnt / self.n >= PROFILE_MAJORITY else "mixed"
258
+ rng = typ in ("int", "float", "date") and self.lo is not None
259
+ lo, hi = (esc(self.lo), esc(self.hi)) if rng else ("-", "-")
260
+ distinct = f">{DISTINCT_CAP}" if len(self.distinct) > DISTINCT_CAP else str(len(self.distinct))
261
+ return f"| {letter} | {header} | {typ} | {self.n} | {lo} | {hi} | {distinct} |"
262
+
263
+
264
+ def chart_line(ch):
265
+ title = "untitled"
266
+ tx = getattr(getattr(ch, "title", None), "tx", None)
267
+ if tx is not None:
268
+ if getattr(tx, "rich", None) is not None:
269
+ title = "".join((r.t or "") for p in tx.rich.p for r in (p.r or [])) or "untitled"
270
+ elif getattr(tx, "strRef", None) is not None:
271
+ title = tx.strRef.f or "untitled"
272
+ series = list(getattr(ch, "series", None) or [])
273
+ refs = [s.val.numRef.f for s in series if getattr(s, "val", None) is not None and s.val.numRef is not None]
274
+ shown = ", ".join(refs[:5]) + (", ..." if len(refs) > 5 else "")
275
+ return f'- {type(ch).__name__} "{esc(title)}" - {len(series)} series ({shown})'
276
+
277
+
278
+ def write_csv(idx, title, cells, R, C, csv_dir):
279
+ if not R:
280
+ return None
281
+ import csv
282
+ os.makedirs(csv_dir, exist_ok=True)
283
+ name = f"s{idx}-{slug(title)}.csv"
284
+ with open(os.path.join(csv_dir, name), "w", encoding="utf-8", newline="") as fh:
285
+ w = csv.writer(fh)
286
+ for row in cells[:R]:
287
+ w.writerow([csv_value(val if val is not None else src) for src, val in row[:C]])
288
+ return f"sheets/{name}"
289
+
290
+
291
+ def data_line(R, C, csv_rel, formulas):
292
+ if not R:
293
+ return "Data: none"
294
+ return f"Data: [{csv_rel}]({csv_rel}) - {R} rows x {C} cols" + (f", {formulas} formulas" if formulas else "")
295
+
296
+
297
+ def preview_and_profile(lines, cells, profiles, R, C, continuation):
298
+ r_lim, c_lim = min(R, PREVIEW_ROWS), min(C, PREVIEW_COLS)
299
+ truncated = r_lim < R or c_lim < C
300
+ lines.append("")
301
+ lines.append(f"Preview (rows 1-{r_lim} of {R}, cols A-{col_letter(c_lim)} of {C}) - full data in the CSV above:" if truncated else f"Content ({R} rows x {C} cols):")
302
+ lines += ["| | " + " | ".join(col_letter(c) for c in range(1, c_lim + 1)) + " |", "|---|" + "---|" * c_lim]
303
+ for r in range(1, r_lim + 1):
304
+ out = []
305
+ for c in range(1, c_lim + 1):
306
+ if (r, c) in continuation:
307
+ out.append("")
308
+ continue
309
+ src, val = cells[r - 1][c - 1]
310
+ if is_formula(src):
311
+ out.append(f"{esc(val) if val is not None else '(no cached result)'} ({esc(src)})")
312
+ else:
313
+ out.append(esc(src))
314
+ lines.append(f"| {r} | " + " | ".join(out) + " |")
315
+ if not truncated:
316
+ return
317
+ lines += ["", "Columns:", "| col | header | type | non-empty | min | max | distinct |", "|---|---|---|---|---|---|---|"]
318
+ for c, p in enumerate(profiles[:C], 1):
319
+ h = cells[0][c - 1][0]
320
+ lines.append(p.row(col_letter(c), esc(h) if isinstance(h, str) and not is_formula(h) else "-"))
321
+
322
+
323
+ def inv_row(idx, name, kind, size, hidden, charts, images, rvs, data):
324
+ return f"| {idx} | {esc(name)} | {kind} | {size} | {'yes' if hidden else 'no'} | {charts} | {images} | {rvs} | {data} |"
325
+
326
+
327
+ def sheet_info(idx, name, kind, hidden, R, C, hr, hc, charts, images, csv_rel):
328
+ return {"index": idx, "name": name, "kind": kind, "hidden": hidden, "rows": R, "cols": C, "hiddenRows": hr, "hiddenCols": hc,
329
+ "charts": charts, "images": images, "rendered": False, "csv": csv_rel}
330
+
189
331
  def mode_xlsx(o):
190
- path, staging, budget = o["path"], o["stagingDir"], o["maxCellsPerSheet"]
191
- notes, images = [], []
332
+ path, staging, csv_dir = o["path"], o["stagingDir"], o["sheetsStagingDir"]
192
333
  if path.lower().endswith(".xls"):
193
334
  return mode_xls(o)
194
335
  import openpyxl
336
+ from openpyxl.chartsheet import Chartsheet
195
337
  wb_f = openpyxl.load_workbook(path, data_only=False)
196
338
  wb_v = openpyxl.load_workbook(path, data_only=True)
197
- inventory, sections = [], []
198
- for idx, ws in enumerate(wb_f.worksheets, 1):
199
- wv = wb_v[ws.title]
200
- rows, cols = ws.max_row, ws.max_column
201
- r_lim, c_lim, truncated = rows, cols, False
202
- if rows * cols > budget:
203
- truncated = True
204
- r_lim = max(1, budget // cols)
205
- if r_lim == 1 and cols > budget:
206
- c_lim = budget
339
+ stem = os.path.splitext(os.path.basename(path))[0]
340
+ notes, images, sheets, render, inv, sections = [], [], [], [], [], []
341
+ for idx, ws in enumerate(wb_f._sheets):
207
342
  hidden = ws.sheet_state != "visible"
208
- inventory.append(f"- {idx}. {ws.title}{' hidden' if hidden else ''} - {rows} x {cols}{' truncated' if truncated else ''}")
209
- lines = [f"## {ws.title}"]
343
+ charts = list(getattr(ws, "_charts", []) or [])
344
+ if isinstance(ws, Chartsheet):
345
+ lines = [f"## {esc(ws.title)} (chartsheet)"]
346
+ if charts:
347
+ lines += ["Charts:"] + [chart_line(ch) for ch in charts]
348
+ render.append(idx)
349
+ lines.append(f"<!--rv:{idx}-->")
350
+ inv.append(inv_row(idx, ws.title, "chartsheet", "-", hidden, len(charts), 0, f"<!--rvs:{idx}-->" if charts else "-", "-"))
351
+ sheets.append(sheet_info(idx, ws.title, "chartsheet", hidden, None, None, 0, 0, len(charts), 0, None))
352
+ sections.append("\n".join(lines))
353
+ continue
354
+ wv = wb_v[ws.title]
355
+ raw_r, raw_c = ws.max_row, ws.max_column
356
+ cells, formulas, R, C = [], 0, 0, 0
357
+ profiles = [ColProfile() for _ in range(raw_c)]
358
+ for r, (fr, vr) in enumerate(zip(ws.iter_rows(min_row=1, max_row=raw_r, max_col=raw_c),
359
+ wv.iter_rows(min_row=1, max_row=raw_r, max_col=raw_c)), 1):
360
+ row = []
361
+ for c, (fc, vc) in enumerate(zip(fr, vr), 1):
362
+ src, val = fc.value, vc.value
363
+ if src is not None:
364
+ R, C = r, max(C, c)
365
+ if is_formula(src):
366
+ formulas += 1
367
+ profiles[c - 1].add("formula" if val is None else cell_class(val), val)
368
+ else:
369
+ profiles[c - 1].add(cell_class(src), src)
370
+ row.append((src, val))
371
+ cells.append(row)
372
+ lines = [f"## {esc(ws.title)}"]
210
373
  if hidden:
211
374
  lines.append("Hidden sheet")
212
375
  merged = [str(r) for r in ws.merged_cells.ranges]
@@ -221,29 +384,13 @@ def mode_xlsx(o):
221
384
  hc += [col_letter(i) for i in range(dim.min, dim.max + 1)]
222
385
  if hc:
223
386
  lines.append("Hidden cols: " + ",".join(hc))
224
- if truncated:
225
- lines.append(f"Truncated: showing rows 1-{r_lim} of {rows}, cols A-{col_letter(c_lim)} of {cols}")
226
- continuation = set()
227
- for rng in ws.merged_cells.ranges:
228
- for r in range(rng.min_row, rng.max_row + 1):
229
- for c in range(rng.min_col, rng.max_col + 1):
230
- if (r, c) != (rng.min_row, rng.min_col):
231
- continuation.add((r, c))
232
- header = "| | " + " | ".join(col_letter(c) for c in range(1, c_lim + 1)) + " |"
233
- lines += [header, "|---|" + "---|" * c_lim]
234
- for r in range(1, r_lim + 1):
235
- cells = []
236
- for c in range(1, c_lim + 1):
237
- if (r, c) in continuation:
238
- cells.append("")
239
- continue
240
- f, v = ws.cell(r, c).value, wv.cell(r, c).value
241
- if isinstance(f, str) and f.startswith("="):
242
- cells.append(f"{esc(v) if v is not None else '(no cached result)'} ({esc(f)})")
243
- else:
244
- cells.append(esc(f))
245
- lines.append(f"| {r} | " + " | ".join(cells) + " |")
246
- for i, img in enumerate(getattr(ws, "_images", []), 1):
387
+ csv_rel = write_csv(idx, ws.title, cells, R, C, csv_dir)
388
+ lines.append(data_line(R, C, csv_rel, formulas))
389
+ if charts:
390
+ lines += ["Charts:"] + [chart_line(ch) for ch in charts]
391
+ raw_images = list(getattr(ws, "_images", []) or [])
392
+ img_lines = []
393
+ for i, img in enumerate(raw_images, 1):
247
394
  try:
248
395
  fmt = (getattr(img, "format", None) or "png").lower()
249
396
  name = f"s{idx}-{i}.{fmt}"
@@ -251,33 +398,60 @@ def mode_xlsx(o):
251
398
  with open(os.path.join(staging, name), "wb") as fh:
252
399
  fh.write(data)
253
400
  images.append({"sheetIndex": idx, "file": name})
254
- lines.append(f"![]({name})")
401
+ img_lines.append(f"- ![{esc(ws.title)} image {i}]({name})")
255
402
  except Exception as exc: # noqa: BLE001
256
403
  notes.append(f"sheet {ws.title}: image {i} not extracted ({type(exc).__name__})")
257
- if not hasattr(ws, "_images"):
258
- notes.append("Images: unavailable")
404
+ if img_lines:
405
+ lines += ["Images:"] + img_lines
406
+ visual = bool(charts or raw_images)
407
+ if visual:
408
+ render.append(idx)
409
+ lines.append(f"<!--rv:{idx}-->")
410
+ if R:
411
+ continuation = set()
412
+ for rng in ws.merged_cells.ranges:
413
+ for r in range(rng.min_row, rng.max_row + 1):
414
+ for c in range(rng.min_col, rng.max_col + 1):
415
+ if (r, c) != (rng.min_row, rng.min_col):
416
+ continuation.add((r, c))
417
+ preview_and_profile(lines, cells, profiles, R, C, continuation)
418
+ inv.append(inv_row(idx, ws.title, "worksheet", f"{R} x {C}", hidden, len(charts), len(raw_images),
419
+ f"<!--rvs:{idx}-->" if visual else "-", f"[{csv_rel}]({csv_rel})" if csv_rel else "-"))
420
+ sheets.append(sheet_info(idx, ws.title, "worksheet", hidden, R, C, len(hr), len(hc), len(charts), len(raw_images), csv_rel))
259
421
  sections.append("\n".join(lines))
260
- md = "## Sheets\n" + "\n".join(inventory) + "\n\n" + "\n\n".join(sections) + "\n"
261
- return {"markdown": md, "images": images, "notes": notes}
262
-
422
+ md = f"# {esc(stem)}\n\n## Sheets\n" + INV_HEADER + "\n".join(inv) + "\n\n" + "\n\n".join(sections) + "\n"
423
+ return {"markdown": md, "images": images, "notes": notes, "sheets": sheets, "renderPages": render, "sheetCount": len(wb_f._sheets)}
263
424
 
264
425
  def mode_xls(o):
265
426
  import xlrd
266
- path, budget = o["path"], o["maxCellsPerSheet"]
427
+ path, csv_dir = o["path"], o["sheetsStagingDir"]
267
428
  book = xlrd.open_workbook(path, formatting_info=True, logfile=sys.stderr)
268
- inventory, sections = [], []
429
+ stem = os.path.splitext(os.path.basename(path))[0]
430
+ sheets, inv, sections = [], [], []
269
431
  for idx in range(book.nsheets):
270
432
  sh = book.sheet_by_index(idx)
271
- rows, cols = sh.nrows, sh.ncols
272
- r_lim, c_lim, truncated = rows, cols, False
273
- if rows * cols > budget:
274
- truncated = True
275
- r_lim = max(1, budget // max(cols, 1))
276
- if r_lim == 1 and cols > budget:
277
- c_lim = budget
278
433
  hidden = sh.visibility != 0
279
- inventory.append(f"- {idx + 1}. {sh.name}{' hidden' if hidden else ''} - {rows} x {cols}{' truncated' if truncated else ''}")
280
- lines = [f"## {sh.name}", "Formulas: unavailable (.xls via xlrd); Images: unavailable"]
434
+ cells, R, C = [], 0, 0
435
+ profiles = [ColProfile() for _ in range(sh.ncols)]
436
+ for r in range(sh.nrows):
437
+ row = []
438
+ for c in range(sh.ncols):
439
+ cell = sh.cell(r, c)
440
+ v = cell.value
441
+ if cell.ctype == xlrd.XL_CELL_DATE:
442
+ v = xlrd.xldate_as_datetime(v, book.datemode)
443
+ elif cell.ctype == xlrd.XL_CELL_BOOLEAN:
444
+ v = bool(v)
445
+ elif cell.ctype == xlrd.XL_CELL_ERROR:
446
+ v = xlrd.error_text_from_code.get(int(v), f"#ERR{int(v)}")
447
+ elif cell.ctype in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK):
448
+ v = None
449
+ if v is not None:
450
+ R, C = r + 1, max(C, c + 1)
451
+ profiles[c].add(cell_class(v), v)
452
+ row.append((v, v))
453
+ cells.append(row)
454
+ lines = [f"## {esc(sh.name)}", "Formulas: unavailable (.xls via xlrd); Images: unavailable"]
281
455
  if hidden:
282
456
  lines.append("Hidden sheet")
283
457
  merged = [f"{col_letter(c0 + 1)}{r0 + 1}:{col_letter(c1)}{r1}" for r0, r1, c0, c1 in sh.merged_cells]
@@ -289,32 +463,16 @@ def mode_xls(o):
289
463
  hc = [col_letter(c + 1) for c, info in sh.colinfo_map.items() if info.hidden]
290
464
  if hc:
291
465
  lines.append("Hidden cols: " + ",".join(hc))
292
- if truncated:
293
- lines.append(f"Truncated: showing rows 1-{r_lim} of {rows}, cols A-{col_letter(c_lim)} of {cols}")
294
- continuation = {(r, c) for r0, r1, c0, c1 in sh.merged_cells for r in range(r0, r1) for c in range(c0, c1) if (r, c) != (r0, c0)}
295
- lines += ["| | " + " | ".join(col_letter(c + 1) for c in range(c_lim)) + " |", "|---|" + "---|" * c_lim]
296
- for r in range(r_lim):
297
- cells = []
298
- for c in range(c_lim):
299
- if (r, c) in continuation:
300
- cells.append("")
301
- continue
302
- cell = sh.cell(r, c)
303
- v = cell.value
304
- if cell.ctype == xlrd.XL_CELL_DATE:
305
- v = xlrd.xldate_as_datetime(v, book.datemode).isoformat()
306
- elif cell.ctype == xlrd.XL_CELL_BOOLEAN:
307
- v = bool(v)
308
- elif cell.ctype == xlrd.XL_CELL_ERROR:
309
- v = xlrd.error_text_from_code.get(int(v), f"#ERR{int(v)}")
310
- elif cell.ctype == xlrd.XL_CELL_EMPTY:
311
- v = None
312
- cells.append(esc(v))
313
- lines.append(f"| {r + 1} | " + " | ".join(cells) + " |")
466
+ csv_rel = write_csv(idx, sh.name, cells, R, C, csv_dir)
467
+ lines.append(data_line(R, C, csv_rel, 0))
468
+ if R:
469
+ continuation = {(r + 1, c + 1) for r0, r1, c0, c1 in sh.merged_cells for r in range(r0, r1) for c in range(c0, c1) if (r, c) != (r0, c0)}
470
+ preview_and_profile(lines, cells, profiles, R, C, continuation)
471
+ inv.append(inv_row(idx, sh.name, "worksheet", f"{R} x {C}", hidden, 0, 0, "-", f"[{csv_rel}]({csv_rel})" if csv_rel else "-"))
472
+ sheets.append(sheet_info(idx, sh.name, "worksheet", hidden, R, C, len(hr), len(hc), 0, 0, csv_rel))
314
473
  sections.append("\n".join(lines))
315
- md = "## Sheets\n" + "\n".join(inventory) + "\n\n" + "\n\n".join(sections) + "\n"
316
- return {"markdown": md, "images": [], "notes": []}
317
-
474
+ md = f"# {esc(stem)}\n\n## Sheets\n" + INV_HEADER + "\n".join(inv) + "\n\n" + XLS_NOTE + "\n\n" + "\n\n".join(sections) + "\n"
475
+ return {"markdown": md, "images": [], "notes": [XLS_NOTE], "sheets": sheets, "renderPages": [], "sheetCount": book.nsheets}
318
476
 
319
477
  def mode_info_excel(o):
320
478
  path = o["path"]
@@ -324,25 +482,57 @@ def mode_info_excel(o):
324
482
  book = xlrd.open_workbook(path, formatting_info=True, logfile=sys.stderr)
325
483
  for idx in range(book.nsheets):
326
484
  sh = book.sheet_by_index(idx)
327
- sheets.append({"name": sh.name, "index": idx + 1, "hidden": sh.visibility != 0, "rows": sh.nrows, "cols": sh.ncols,
328
- "hiddenRows": sum(1 for i in sh.rowinfo_map.values() if i.hidden),
329
- "hiddenCols": sum(1 for i in sh.colinfo_map.values() if i.hidden)})
485
+ sheets.append(sheet_info(idx, sh.name, "worksheet", sh.visibility != 0, sh.nrows, sh.ncols,
486
+ sum(1 for i in sh.rowinfo_map.values() if i.hidden), sum(1 for i in sh.colinfo_map.values() if i.hidden), 0, 0, None))
330
487
  else:
331
488
  import openpyxl
489
+ from openpyxl.chartsheet import Chartsheet
332
490
  wb = openpyxl.load_workbook(path, data_only=True)
333
- for idx, ws in enumerate(wb.worksheets, 1):
491
+ for idx, ws in enumerate(wb._sheets):
492
+ hidden = ws.sheet_state != "visible"
493
+ charts = len(getattr(ws, "_charts", []) or [])
494
+ if isinstance(ws, Chartsheet):
495
+ sheets.append(sheet_info(idx, ws.title, "chartsheet", hidden, None, None, 0, 0, charts, 0, None))
496
+ continue
334
497
  hc = sum(d.max - d.min + 1 for d in ws.column_dimensions.values() if d.hidden)
335
- sheets.append({"name": ws.title, "index": idx, "hidden": ws.sheet_state != "visible", "rows": ws.max_row, "cols": ws.max_column,
336
- "hiddenRows": sum(1 for d in ws.row_dimensions.values() if d.hidden), "hiddenCols": hc})
498
+ hr = sum(1 for d in ws.row_dimensions.values() if d.hidden)
499
+ sheets.append(sheet_info(idx, ws.title, "worksheet", hidden, ws.max_row, ws.max_column, hr, hc, charts, len(getattr(ws, "_images", []) or []), None))
337
500
  return {"sheets": sheets}
338
501
 
502
+ def mode_render_pages(o):
503
+ import math
504
+ import pymupdf
505
+ doc = pymupdf.open(o["path"])
506
+ expected = o["expectedPages"]
507
+ if doc.page_count != expected:
508
+ return {"ok": False, "reason": f"page-count mismatch ({doc.page_count} vs {expected})"}
509
+ fmt, dpi, staging = o["imageFormat"], o["imageDpi"], o["stagingDir"]
510
+ rendered, failed = [], []
511
+ for idx in o["sheetIndices"]:
512
+ try:
513
+ page = doc[idx]
514
+ w, h = page.rect.width, page.rect.height
515
+ if w < MIN_PAGE_PT or h < MIN_PAGE_PT:
516
+ failed.append({"idx": idx, "reason": f"rendered view degenerate (page {w:.0f} x {h:.0f} pt)"})
517
+ continue
518
+ eff = min(dpi, math.floor(math.sqrt(MAX_RENDER_PX / (w * h / 72 ** 2))))
519
+ if eff < MIN_RENDER_DPI:
520
+ failed.append({"idx": idx, "reason": f"rendered view too large (page {w:.0f} x {h:.0f} pt)"})
521
+ continue
522
+ name = f"s{idx}.{fmt}"
523
+ page.get_pixmap(dpi=eff).save(os.path.join(staging, name))
524
+ rendered.append({"idx": idx, "file": name, "dpi": eff})
525
+ except Exception as exc: # noqa: BLE001
526
+ failed.append({"idx": idx, "reason": f"render failed: {type(exc).__name__}: {exc}"[:300]})
527
+ return {"ok": True, "rendered": rendered, "failed": failed}
528
+
339
529
 
340
- MODES = {"info": None, "pdf-primary": mode_pdf_primary, "pdf-fallback": mode_pdf_fallback, "xlsx": mode_xlsx}
530
+ MODES = {"info": None, "pdf-primary": mode_pdf_primary, "pdf-fallback": mode_pdf_fallback, "xlsx": mode_xlsx, "render-pages": mode_render_pages}
341
531
 
342
532
 
343
533
  def main():
344
534
  if len(sys.argv) != 2 or sys.argv[1] not in MODES:
345
- print("usage: doc_to_md.py <info|pdf-primary|pdf-fallback|xlsx> (options JSON on stdin)", file=sys.stderr)
535
+ print("usage: doc_to_md.py <info|pdf-primary|pdf-fallback|xlsx|render-pages> (options JSON on stdin)", file=sys.stderr)
346
536
  return 1
347
537
  mode = sys.argv[1]
348
538
  o = json.loads(sys.stdin.read() or "{}")