Spaces:
Running
Running
File size: 31,982 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 |
# xlsx reading, v2. The pieces:
# coordinate grid (data islands, real row/column numbers) / formulas in their own block
# (homogeneous fills grouped by R1C1, i.e. OOXML
# shared-formula semantics) / empty formula cache → LibreOffice recalculates into a
# **new file** (the source is never modified) / rendered values
# (a light number_format renderer; LibreOffice getString is the later upgrade) /
# merged/styles/cond
# aggregated at sheet level / charts parsed straight from chart*.xml inside the zip
# (openpyxl load discards charts) / pivots contribute
# meta only, values not expanded (re-read with the cell_range parameter) / Excel Tables
# compress through CSV (meta+preview).
# Conventions: inline code `…` = parser-added, not file content; only deviations from the defaults are noted; headers are never guessed.
import datetime as _dt
import os
import re
import shutil
import tempfile
import xml.etree.ElementTree as _ET
import zipfile
_GAP = 2 # data-island split threshold: >=2 consecutive blank rows/columns split a block (split condition diff > _GAP)
_TABLE_FULL = 20 # an Excel Table with <=20 data rows is emitted whole, otherwise meta+preview
_PREVIEW = 20 # preview row count for a large table
_NSC = "{http://schemas.openxmlformats.org/drawingml/2006/chart}"
_NSDM = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
def _x_col(n):
from openpyxl.utils import get_column_letter
return get_column_letter(n)
def _x_esc(s):
# Backtick escaping: a cell whose text contains ` must not collide with our meta markers (inline code)
return str(s).replace("\n", " ").replace("|", "\\|").replace("`", "\\`")
def _x_ref(r1, c1, r2, c2):
a = f"{_x_col(c1)}{r1}"
b = f"{_x_col(c2)}{r2}"
return a if a == b else f"{a}:{b}"
# ---------- Rendered values (light number_format rendering; anything it cannot cover degrades honestly to the stored value + fmt) ----------
def _x_render(cell):
"""cell → display text. Dates normalised to ISO; percent / scientific / thousands /
currency / negative-in-parens rendered locally;
unrenderable → the stored value as-is (its number_format is visible on the sheet-level
`number-format:` line).
No per-cell fmt/raw annotation: formats usually run down a column, so per-cell notes
are redundant and risk clashing with content backticks."""
v = cell.value
if v is None:
return ""
if isinstance(v, _dt.datetime):
if v.hour == v.minute == v.second == 0:
return v.strftime("%Y-%m-%d")
return v.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(v, (_dt.date, _dt.time)):
return v.isoformat()
fmt = cell.number_format or "General"
if isinstance(v, bool) or not isinstance(v, (int, float)) or fmt in ("General", "@"):
return _x_esc(v)
sec = fmt.split(";")
f = sec[1] if (v < 0 and len(sec) > 1) else sec[0]
neg_paren = v < 0 and "(" in f
av = abs(v) if neg_paren else v
try:
if "%" in f:
m = re.search(r"0\.(0+)%", f)
d = len(m.group(1)) if m else 0
out = f"{av * 100:.{d}f}%"
return f"({out})" if neg_paren else out
if re.search(r"[0#]\.?0*E\+?0+", f, re.I):
m = re.search(r"\.(0+)E", f, re.I)
d = len(m.group(1)) if m else 2
return f"{v:.{d}E}"
if re.search(r"[0#],(?![0#])", f): # trailing comma = thousands scaling (e.g. #,##0,,"M") → do not render
return _x_esc(v)
if "#,##" in f:
m = re.search(r"0\.(0+)", f)
d = len(m.group(1)) if m else 0
s = f"{av:,.{d}f}"
if "$" in f: # any variant containing $ ('"$"', '$', the '[$$-409]' locale form)
s = "$" + s
return f"({s})" if neg_paren else s
except Exception:
pass
return _x_esc(v)
def _x_numfmt_lines(wsf, coords):
"""Aggregate number_format onto one sheet-level line (grouped by format + range-compressed) instead of per-cell notes."""
fmts = {}
for (r, c) in coords:
f = wsf.cell(row=r, column=c).number_format or "General"
if f not in ("General", "@"):
fmts.setdefault(f, set()).add((r, c))
if not fmts:
return ("number-format", [])
bits = [f"{f} {_x_compress(cs)}" for f, cs in sorted(fmts.items())]
return ("number-format", bits)
# ---------- recalc: when the formula cache is empty, LibreOffice recalculates into a **new file** ----------
_X_MACRO = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
<script:module xmlns:script="http://openoffice.org/2000/script" script:name="Module1" \
script:language="StarBasic">
Sub RecalculateAndSave()
ThisComponent.calculateAll()
ThisComponent.store()
ThisComponent.close(True)
End Sub
</script:module>"""
def _x_recalc_copy(path):
"""Copy to a new file → LibreOffice macro calculateAll recalculates and saves that copy
→ return the copy's path.
The source is never modified. soffice unavailable or failing returns None (the caller degrades and marks it uncached)."""
if not shutil.which("soffice"):
return None
try:
env = dict(os.environ)
env["SAL_USE_VCLPLUGIN"] = "svp"
macro_dir = os.path.expanduser("~/.config/libreoffice/4/user/basic/Standard")
if not os.path.isdir(macro_dir): # first run: initialise the profile (its default contains an empty Module1)
subprocess.run(["soffice", "--headless", "--terminate_after_init"],
capture_output=True, timeout=30, env=env)
os.makedirs(macro_dir, exist_ok=True)
mf = os.path.join(macro_dir, "Module1.xba")
try:
ok = "RecalculateAndSave" in open(mf, encoding="utf-8").read()
except OSError:
ok = False
if not ok:
with open(mf, "w", encoding="utf-8") as fh:
fh.write(_X_MACRO)
new = os.path.join(tempfile.mkdtemp(prefix="recalc_"), os.path.basename(path))
shutil.copy2(path, new)
cmd = ["soffice", "--headless", "--norestore",
"vnd.sun.star.script:Standard.Module1.RecalculateAndSave"
"?language=Basic&location=application", os.path.abspath(new)]
r = subprocess.run(cmd, capture_output=True, timeout=90, env=env)
return new if r.returncode == 0 else None
except Exception:
return None
# ---------- Data islands / range compression ----------
def _x_islands(coords):
"""Non-empty coordinate set → list of data-island bboxes. A row/column gap > _GAP
splits a block (three passes: rows → columns → rows).
Known risk: a title separated from its data by more than _GAP blank rows gets split off; the conservative threshold limits how often that bites."""
if not coords:
return []
def split(cs, ax):
vals = sorted({t[ax] for t in cs})
groups, cur = [], [vals[0]]
for v in vals[1:]:
if v - cur[-1] > _GAP:
groups.append(set(cur))
cur = [v]
else:
cur.append(v)
groups.append(set(cur))
return [{t for t in cs if t[ax] in g} for g in groups]
blocks = [set(coords)]
for ax in (0, 1, 0):
blocks = [b for blk in blocks for b in split(blk, ax)]
out = []
for b in blocks:
rs = [t[0] for t in b]
cs = [t[1] for t in b]
out.append((min(rs), min(cs), max(rs), max(cs)))
return sorted(out)
def _x_compress(coords):
"""Coordinate set → 'A1:B3,C5' rectangle compression (greedy: extend right, then down)."""
cs = set(coords)
out = []
while cs:
r, c = min(cs)
w = 1
while (r, c + w) in cs:
w += 1
h = 1
while all((r + h, cc) in cs for cc in range(c, c + w)):
h += 1
for rr in range(r, r + h):
for cc in range(c, c + w):
cs.discard((rr, cc))
out.append(_x_ref(r, c, r + h - 1, c + w - 1))
return ",".join(out)
# ---------- Grid (spreadsheet view: column letters on the first row, row numbers in the first column, real coordinates) ----------
def _x_has_formula(fc):
v = fc.value
return (isinstance(v, str) and v.startswith("=")) or \
v.__class__.__name__ == "ArrayFormula"
def _x_grid(wsv, wsf, bbox, mark_uncached):
r1, c1, r2, c2 = bbox
lines = ["| | " + " | ".join(_x_col(c) for c in range(c1, c2 + 1)) + " |",
"| --- |" + " --- |" * (c2 - c1 + 1)]
for r in range(r1, r2 + 1):
row, any_ = [], False
for c in range(c1, c2 + 1):
vc = wsv.cell(row=r, column=c)
if vc.value is None and mark_uncached and _x_has_formula(wsf.cell(row=r, column=c)):
row.append("`uncached`")
any_ = True
continue
disp = _x_render(vc)
if disp:
any_ = True
row.append(disp)
if any_:
lines.append(f"| {r} | " + " | ".join(row) + " |")
return lines
# ---------- Formula blocks (homogeneous fill → range + R1C1; the general Excel definition, same as OOXML shared formula) ----------
_X_REF_RE = re.compile(r"(?<![A-Za-z0-9_$])(\$?)([A-Za-z]{1,3})(\$?)([0-9]{1,7})(?![\w(])")
def _x_r1c1(formula, ar, ac):
"""A1 → R1C1 (relative to anchor (ar,ac)). A homogeneous fill converts to one identical string, so it can be grouped by range."""
from openpyxl.utils import column_index_from_string
def conv(m):
cd, cl, rd, rn = m.groups()
col = column_index_from_string(cl.upper())
row = int(rn)
rp = f"R{row}" if rd else ("R" if row == ar else f"R[{row - ar}]")
cp = f"C{col}" if cd else ("C" if col == ac else f"C[{col - ac}]")
return rp + cp
return _X_REF_RE.sub(conv, formula)
def _x_formula_lines(wsf):
fcells = {}
for row in wsf.iter_rows():
for c in row:
v = c.value
if isinstance(v, str) and v.startswith("="):
fcells[(c.row, c.column)] = v
elif v.__class__.__name__ == "ArrayFormula":
t = getattr(v, "text", "") or ""
fcells[(c.row, c.column)] = t if t.startswith("=") else "=" + t
if not fcells:
return ("formulas", [])
done, items = set(), []
for (r, c) in sorted(fcells, key=lambda t: (t[1], t[0])): # column-major → find vertical fills first
if (r, c) in done:
continue
base = _x_r1c1(fcells[(r, c)], r, c)
k = 1
while (r + k, c) in fcells and (r + k, c) not in done and \
_x_r1c1(fcells[(r + k, c)], r + k, c) == base:
k += 1
if k > 1:
done.update((r + i, c) for i in range(k))
items.append(f"{_x_col(c)}{r}:{_x_col(c)}{r + k - 1} {base}")
continue
k = 1
while (r, c + k) in fcells and (r, c + k) not in done and \
_x_r1c1(fcells[(r, c + k)], r, c + k) == base:
k += 1
done.update((r, c + i) for i in range(max(k, 1)))
if k > 1:
items.append(f"{_x_col(c)}{r}:{_x_col(c + k - 1)}{r} {base}")
else:
items.append(f"{_x_col(c)}{r} {fcells[(r, c)]}") # an isolated formula keeps its A1 text
return ("formulas", items) # one entry per formula group; the assembler puts each on its own line inside the ```meta block
# ---------- Sheet-level aggregation: merged / styles / cond / extras (links · comments · dropdowns) ----------
def _x_merged_lines(wsf):
"""For each merged region, note the cell holding the visible text (an xlsx merge always stores the value in the top-left cell)."""
try:
rs = []
for r in sorted(str(r) for r in wsf.merged_cells.ranges):
origin = r.split(":")[0]
rs.append(f"{r} (value at {origin})")
except Exception:
rs = []
return ("merged", rs)
def _x_color(c):
try:
if c is not None and getattr(c, "type", None) == "rgb" and c.rgb:
s = str(c.rgb)
if len(s) == 8:
s = s[2:]
if s != "000000":
return "#" + s
except Exception:
pass
return ""
def _x_style_lines(wsf, coords):
"""Styles get one line per fine-grained category: bg-color / font-color / bold / italic / underline."""
bg, fontc = {}, {}
bold, italic, underline = set(), set(), set()
for (r, c) in coords:
cell = wsf.cell(row=r, column=c)
try:
fill = cell.fill
if fill is not None and fill.patternType == "solid":
col = _x_color(fill.fgColor)
if col and col != "#FFFFFF":
bg.setdefault(col, set()).add((r, c))
except Exception:
pass
try:
col = _x_color(cell.font.color)
if col:
fontc.setdefault(col, set()).add((r, c))
if cell.font.bold:
bold.add((r, c))
if cell.font.italic:
italic.add((r, c))
if cell.font.underline and cell.font.underline != "none":
underline.add((r, c))
except Exception:
pass
items = []
if bg:
items.append("bg-color: " + " | ".join(
f"{col} {_x_compress(cs)}" for col, cs in sorted(bg.items())))
if fontc:
items.append("font-color: " + " | ".join(
f"{col} {_x_compress(cs)}" for col, cs in sorted(fontc.items())))
if bold:
items.append(f"bold: {_x_compress(bold)}")
if italic:
items.append(f"italic: {_x_compress(italic)}")
if underline:
items.append(f"underline: {_x_compress(underline)}")
return ("styles", items)
def _x_cond_lines(wsf):
bits = []
try:
for cf in wsf.conditional_formatting:
rng = str(cf.sqref).replace(" ", ",")
for rule in cf.rules:
t = rule.type
if t == "colorScale":
try:
# val is meaningless for min/max types → show the type name; numeric types show val
vs = [v.type if v.type in ("min", "max") else
f"{v.type}:{v.val}" for v in rule.colorScale.cfvo]
bits.append(f"{rng} color-scale({'→'.join(vs)})")
except Exception:
bits.append(f"{rng} color-scale")
elif t == "dataBar":
bits.append(f"{rng} data-bar")
elif t == "cellIs":
fml = rule.formula[0] if rule.formula else ""
bits.append(f"{rng} cellIs {rule.operator} {fml}")
else:
bits.append(f"{rng} {t}")
except Exception:
pass
return ("cond", bits)
def _x_extra_lines(wsf):
"""The remaining handles whose meaning is not in the value layer: hyperlink URLs, comments, data-validation dropdowns."""
bits = []
for row in wsf.iter_rows():
for c in row:
try:
if c.hyperlink is not None and getattr(c.hyperlink, "target", None):
bits.append(f"link {c.coordinate}→{c.hyperlink.target}")
if c.comment is not None and c.comment.text:
txt = c.comment.text.strip().replace("\n", " ")[:60]
bits.append(f'comment {c.coordinate}:"{txt}"')
except Exception:
pass
try:
for dv in wsf.data_validations.dataValidation:
if dv.type == "list":
bits.append(f"dropdown {dv.sqref} {dv.formula1}")
except Exception:
pass
return ("extras", bits)
# ---------- Relational table → the "table with meta" representation (shared by Excel Tables and csv) ----------
def _x_to_num(v):
"""v → float or None (for the column schema's numeric verdict). bool is excluded; numeric strings (csv) count."""
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
try:
return float(v)
except ValueError:
return None
return None
def _x_table_md(header, rows, name=None, ref=None, preview=_PREVIEW):
"""Relational table → the "table with meta" representation (**no coordinates, no grid**),
shared by Excel Tables and csv.
preview = when the row count exceeds _TABLE_FULL, only the first ``preview`` rows are
listed (Excel Tables default to _PREVIEW=20; use cell_range to fetch more);
preview=None → everything, untruncated (used by csv, which has no cell_range; the
layer above paginates all rows via offset/max_chars).
```meta
▸ table ["name": ref] (N data rows)
▸ columns ← only when row count > _TABLE_FULL
col: num, min=X, max=Y ← numeric column
col: str, K uniq: [..] ← text column (<=20 uniq: all listed)
col: str, K uniq, first 20: [..] ← text column (>20 uniq: first 20 only)
▸ preview: first _PREVIEW rows
```
| col | col | ... | ← markdown data table (whole thing when <=_TABLE_FULL, else the first _PREVIEW rows)
rows: list of data rows, each cell = (raw_value, display_str). raw feeds the column schema's numeric verdict, display goes into the table body."""
ncol = len(header)
nrows = len(rows)
big = nrows > _TABLE_FULL # only a large table gets a column schema (csv gets one at full size too, as an overview)
truncate = preview is not None and big # Excel Tables only: a large one is cut to preview rows
if name and ref:
title = f'▸ table "{name}": {ref} ({nrows} data rows)'
elif name:
title = f'▸ table "{name}" ({nrows} data rows)'
else:
title = f"▸ table ({nrows} data rows)"
lines = ["```meta", title]
if big:
lines.append("▸ columns")
for ci in range(ncol):
raws = [rows[r][ci][0] for r in range(nrows)
if ci < len(rows[r]) and rows[r][ci][0] not in (None, "")]
nums = [n for n in (_x_to_num(v) for v in raws) if n is not None]
if raws and len(nums) >= len(raws) * 0.9:
lines.append(f" {header[ci]}: num, min={min(nums):g}, max={max(nums):g}")
else:
uniq = sorted({str(v) for v in raws})
if len(uniq) > 20:
lines.append(f" {header[ci]}: str, {len(uniq)} uniq, first 20: {uniq[:20]}")
else:
lines.append(f" {header[ci]}: str, {len(uniq)} uniq: {uniq}")
if truncate:
lines.append(f"▸ preview: first {preview} rows")
lines.append("```")
lines.append("| " + " | ".join(_x_esc(h) for h in header) + " |")
lines.append("|" + " --- |" * ncol)
end = min(preview, nrows) if truncate else nrows
for r in range(end):
cells = [rows[r][ci][1] if ci < len(rows[r]) else "" for ci in range(ncol)]
lines.append("| " + " | ".join(cells) + " |")
return lines
def _x_table_regions(wsv, wsf):
"""Excel Table → [(top-left anchor, lines)]; inserted into the sheet's region sequence by position. Rendering goes through the shared _x_table_md."""
from openpyxl.utils import range_boundaries
regions, mask = [], set()
tables = getattr(wsf, "tables", {}) or {}
for name in tables:
ref = tables[name].ref if hasattr(tables[name], "ref") else str(tables[name])
try:
c1, r1, c2, r2 = range_boundaries(ref)
except Exception:
continue
mask.update((r, c) for r in range(r1, r2 + 1) for c in range(c1, c2 + 1))
header = [str(wsv.cell(row=r1, column=c).value or "") for c in range(c1, c2 + 1)]
rows = []
for r in range(r1 + 1, r2 + 1):
cells = [wsv.cell(row=r, column=c) for c in range(c1, c2 + 1)]
rows.append([(cell.value, _x_render(cell)) for cell in cells])
lines = _x_table_md(header, rows, name=name, ref=ref)
regions.append(((r1, c1), lines))
return regions, mask
def _csv_to_md(path):
"""csv/tsv → the "table with meta" representation (reuses _x_table_md).
First row = header, the rest = data rows; the delimiter follows the extension (.tsv → tab). An empty string = an empty value.
Output volume is controlled by _x_table_md's preview plus the layer above's max_chars/offset pagination, same as xlsx."""
import csv as _csv
import io as _io
import os as _os
sep = "\t" if path.lower().endswith(".tsv") else ","
# Encoding via _decode_bytes (the core fragment, CJK-friendly) — hardcoding utf-8+replace
# turns a Shift-JIS / GBK csv entirely into replacement characters
# instead of readable text.
with open(path, "rb") as f:
_text = _decode_bytes(f.read())
raw_rows = list(_csv.reader(_io.StringIO(_text, newline=""), delimiter=sep))
name = _os.path.basename(path)
if not raw_rows:
return f"# CSV: {name}\n\n(empty)"
header = [str(h) for h in raw_rows[0]]
data = [[((v if v != "" else None), _x_esc(v)) for v in r] for r in raw_rows[1:]]
out = ["<!-- csv readout: relational table + column meta "
"(parser-added ```meta / `…`). -->", ""]
out += _x_table_md(header, data, name=name, preview=None) # csv: all rows, paginated via offset/max_chars
return "\n".join(out)
# ---------- pivot: meta only, values not expanded (re-read with cell_range) ----------
def _x_pivot_regions(wsf):
"""pivot → [(top-left anchor, description string)]; meta only, values not expanded (re-read with cell_range)."""
from openpyxl.utils import range_boundaries
regions, mask = [], set()
for p in getattr(wsf, "_pivots", []) or []:
loc, anchor = "?", (1, 1)
try:
loc = p.location.ref
c1, r1, c2, r2 = range_boundaries(loc)
anchor = (r1, c1)
mask.update((r, c) for r in range(r1, r2 + 1) for c in range(c1, c2 + 1))
except Exception:
pass
src = "?"
try:
w = p.cache.cacheSource.worksheetSource
src = f"{w.sheet}!{w.ref}"
except Exception:
pass
rows, cols, vals = [], [], []
try:
names = [f.name for f in p.cache.cacheFields]
rows = [names[f.x] for f in (p.rowFields or []) if 0 <= f.x < len(names)]
cols = [names[f.x] for f in (p.colFields or []) if 0 <= f.x < len(names)]
vals = [d.name for d in (p.dataFields or [])]
except Exception:
pass
regions.append((anchor, f"at {loc} source={src} rows={rows} cols={cols} "
f"values={vals} — values not expanded; "
f"re-read with cell_range"))
return regions, mask
# ---------- chart: parsed straight from chart*.xml inside the zip (openpyxl load discards charts, so they cannot be read back) ----------
def _x_chart_lines(path):
out = {}
try:
z = zipfile.ZipFile(path)
except Exception:
return out
for name in z.namelist():
if not re.fullmatch(r"xl/charts/chart\d+\.xml", name):
continue
try:
root = _ET.fromstring(z.read(name))
except Exception:
continue
plot = root.find(f".//{_NSC}plotArea")
if plot is None:
continue
kinds = [e.tag[len(_NSC):].replace("Chart", "") for e in plot
if e.tag.startswith(_NSC) and e.tag.endswith("Chart")]
tl = root.find(f".//{_NSC}title")
title = "".join(t.text or "" for t in tl.iter(f"{_NSDM}t")) if tl is not None else ""
sers, cats, trends, cats_kw = [], "", [], "cats"
for ser in plot.iter(f"{_NSC}ser"):
v = ser.find(f"{_NSC}val")
if v is None:
v = ser.find(f"{_NSC}yVal") # scatter / bubble: yVal stands in for val
if v is not None:
fel = v.find(f".//{_NSC}f")
if fel is not None and fel.text:
sers.append(fel.text)
ce = ser.find(f"{_NSC}cat")
if ce is None:
ce = ser.find(f"{_NSC}xVal") # scatter: xVal holds x values, not categories
if ce is not None:
cats_kw = "x"
if ce is not None and not cats:
fel = ce.find(f".//{_NSC}f")
if fel is not None and fel.text:
cats = fel.text
for t in ser.iter(f"{_NSC}trendline"):
tt = t.find(f"{_NSC}trendlineType")
trends.append(tt.get("val") if tt is not None else "linear")
sheet = ""
for ref in sers + ([cats] if cats else []):
m = re.match(r"'?([^'!]+)'?!", ref)
if m:
sheet = m.group(1)
break
bit = "+".join(kinds) or "?"
if title:
bit += f' title="{title}"'
if sers:
bit += f" series={','.join(sers)}"
if cats:
bit += f" {cats_kw}={cats}"
if trends:
bit += f" trendline={','.join(trends)}"
out.setdefault(sheet, []).append(bit) # description string; the `chart N:` prefix is added once at the exit
z.close()
return out
# ---------- Main entry point ----------
def _xlsx_to_md(path, cell_range=None):
"""xlsx → markdown (the v2 rich extraction).
With cell_range='Sheet1!A3:D15' only that range is read (re-reading a pivot result or part of a large file)."""
_ensure("openpyxl", "openpyxl")
from openpyxl import load_workbook
from openpyxl.utils import range_boundaries
# Skip eager pivotCache parsing: we never read its contents (pivot meta comes from the
# pivotTable definition),
# and a malformed or huge pivotCache can stall a full load_workbook (measured on one
# dashboard: 120s → 0.09s).
# Any cacheId → None; pivotTable definitions still parse. try/except is the floor (if
# the openpyxl API changes, behaviour reverts to the original).
try:
import collections as _c
from openpyxl.reader.workbook import WorkbookParser as _WP
_WP.pivot_caches = property(lambda self: _c.defaultdict(lambda: None))
except Exception:
pass
wbf = load_workbook(path, data_only=False)
wbv = load_workbook(path, data_only=True)
head = ["<!-- xlsx readout. Batch sheet metadata is collected per sheet in a ```meta"
" fenced block (parser-added, not file content); each category is marked with"
" a ▸ header (▸ formulas / ▸ number-format / ▸ merged / ▸ styles / ▸ cond /"
" ▸ extras) with one item per line for quick scanning. Inline `…` marks"
" per-cell notes (uncached / pivot / data region). Grid header = column"
" letters, first column = row numbers (real coordinates); homogeneous formula"
" fills grouped as R1C1; dates ISO-normalized; uncached = empty formula cache."
" Hidden/very-hidden sheets are flagged and NOT expanded — read them on"
" demand by passing cell_range=\"<sheet>!A1:..\". -->"]
def needs_recalc():
for wsf_ in wbf.worksheets:
if wsf_.sheet_state != "visible": # a hidden sheet is not shown and does not trigger a recalc
continue
wsv_ = wbv[wsf_.title]
for row in wsf_.iter_rows():
for c in row:
if _x_has_formula(c) and \
wsv_.cell(row=c.row, column=c.column).value is None:
return True
return False
mark_uncached = False
if needs_recalc():
new = _x_recalc_copy(path)
if new:
wbv = load_workbook(new, data_only=True)
head.append("`recalc: done via LibreOffice on a temp copy (source untouched)`")
else:
mark_uncached = True
head.append("`recalc: soffice unavailable; empty formula caches marked uncached`")
if cell_range: # re-read: dump only the requested range
if "!" in cell_range:
sn, rng = cell_range.split("!", 1)
sn = sn.strip("'")
else:
sn, rng = wbv.sheetnames[0], cell_range
if sn not in wbv.sheetnames:
return f"[read_file error] sheet not found: {sn}"
try:
c1, r1, c2, r2 = range_boundaries(rng)
except Exception as e:
return f"[read_file error] invalid cell_range: {rng} ({e})"
out = [*head, "", f"<!-- range {sn}!{rng} -->", ""]
out += _x_grid(wbv[sn], wbf[sn], (r1, c1, r2, c2), mark_uncached)
return "\n".join(out)
charts = _x_chart_lines(path)
out = list(head)
for wsf in wbf.worksheets:
if wsf.sheet_state != "visible": # hidden sheet: annotate only, do not read the content; the LLM re-reads on demand
state = "very hidden" if wsf.sheet_state == "veryHidden" else "hidden"
out += ["", f"<!-- sheet: {wsf.title} -->",
f'`{state} — content not read; pass '
f'cell_range="{wsf.title}!A1:.." to read on demand`']
continue
wsv = wbv[wsf.title]
out += ["", f"<!-- sheet: {wsf.title} -->"]
coords = {(c.row, c.column) for row in wsf.iter_rows() for c in row
if c.value is not None}
# Data first: data islands / Excel Tables / pivots are all emitted sorted by top-left anchor (top-left → bottom-right)
tregions, tmask = _x_table_regions(wsv, wsf)
pregions, pmask = _x_pivot_regions(wsf)
islands = _x_islands(coords - tmask - pmask)
regions = [((b[0], b[1]), "region", b) for b in islands]
regions += [(a, "table", ls) for a, ls in tregions]
regions += [(a, "pivot", d) for a, d in pregions]
regions.sort(key=lambda t: t[0])
n_reg, n_piv = len(islands), len(pregions)
ri = 0
pivot_items = []
for _a, kind, payload in regions:
if kind == "region":
ri += 1
label = f"data region {ri}" if n_reg > 1 else "data region"
out += ["", f"`{label}: {_x_ref(*payload)}`"]
out += _x_grid(wsv, wsf, payload, mark_uncached)
elif kind == "table":
out += ["", *payload]
else: # pivot: collected and placed last under ▸ pivots (values not expanded, no locating table)
pivot_items.append(payload)
_ = n_piv
# Bulk sheet-level meta goes into one ```meta fence: one ▸ marker per category, entries one per line.
# pivots/charts join it too (data first, annotations after); single-point markers (data region / uncached / recalc) stay inline.
sections = [_x_formula_lines(wsf), _x_numfmt_lines(wsf, coords),
_x_merged_lines(wsf), _x_style_lines(wsf, coords),
_x_cond_lines(wsf), _x_extra_lines(wsf),
("pivots", pivot_items),
("charts", charts.get(wsf.title, []))]
metalines = []
for label, items in sections:
if items:
metalines.append(f"▸ {label}")
metalines += [f" {it}" for it in items]
if metalines:
out += ["", "```meta", *metalines, "```"]
if not coords:
out.append("(empty)")
if charts.get(""):
out += ["", "<!-- charts (sheet unresolved) -->", "```meta", "▸ charts",
*[f" {c}" for c in charts[""]], "```"]
return "\n".join(out)
|