338 lines
13 KiB
Python
338 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Reference-aware row/column insert and delete for .xlsx workbooks.
|
|
|
|
Unlike plain openpyxl insert_rows/delete_cols (and xlsx_edit.py's thin
|
|
wrappers), this script also rewrites everything that points at the moved
|
|
cells:
|
|
|
|
* formula references in ALL sheets, including absolute refs ($B$2),
|
|
ranges (B2:B9), and cross-sheet refs ('My Sheet'!A1 / Data!$B$8).
|
|
References into a deleted region become #REF!.
|
|
* merged-cell ranges (shifted; expanded when they span the insertion
|
|
point; removed when fully deleted)
|
|
* autofilter range, freeze panes, data-validation ranges,
|
|
conditional-formatting applied ranges (sqref)
|
|
* native table (ListObject) refs on the edited sheet
|
|
* workbook-scope defined names that point at the edited sheet
|
|
* row heights / column widths
|
|
|
|
It prints a JSON report of every rewrite it made and lists what it
|
|
could NOT shift (chart anchors, images, conditional-format RULE
|
|
formulas). Full rules and limits: references/restructuring.md.
|
|
|
|
One structural operation per invocation:
|
|
|
|
Usage:
|
|
xlsx_restructure.py book.xlsx --sheet Data --insert-rows 3:2
|
|
xlsx_restructure.py book.xlsx --sheet Data --delete-rows 5
|
|
xlsx_restructure.py book.xlsx --sheet Data --insert-cols B:1 --out new.xlsx
|
|
xlsx_restructure.py book.xlsx --sheet Data --delete-cols 4:2
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
from openpyxl import load_workbook
|
|
from openpyxl.formatting.formatting import ConditionalFormattingList
|
|
from openpyxl.utils import (column_index_from_string, get_column_letter,
|
|
range_boundaries)
|
|
|
|
# A1-style reference, optionally sheet-qualified, optionally a range.
|
|
# Guards: not preceded by a word char/$/. (avoids ABC123 identifiers) and
|
|
# not followed by a word char or "(" (avoids function names like LOG10().
|
|
REF_RE = re.compile(
|
|
r"(?<![\w$.:])"
|
|
r"(?P<sheet>(?:'(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!)?"
|
|
r"(?P<start>\$?[A-Za-z]{1,3}\$?[0-9]{1,7})"
|
|
r"(?::(?P<end>\$?[A-Za-z]{1,3}\$?[0-9]{1,7}))?"
|
|
r"(?![\w(])")
|
|
STRING_RE = re.compile(r'"(?:[^"]|"")*"')
|
|
COORD_RE = re.compile(r"^(\$?)([A-Za-z]{1,3})(\$?)([0-9]+)$")
|
|
|
|
|
|
def shift_point(v, idx, n, delete):
|
|
"""New 1-based index for a single row/col, or None if deleted."""
|
|
if delete:
|
|
if v < idx:
|
|
return v
|
|
if v >= idx + n:
|
|
return v - n
|
|
return None
|
|
return v + n if v >= idx else v
|
|
|
|
|
|
def shift_span(a, b, idx, n, delete):
|
|
"""New (start, end) for an inclusive span, or None if fully deleted."""
|
|
if delete:
|
|
na = a if a < idx else (a - n if a >= idx + n else idx)
|
|
nb = b if b < idx else (b - n if b >= idx + n else idx - 1)
|
|
return None if na > nb else (na, nb)
|
|
return (a + n if a >= idx else a, b + n if b >= idx else b)
|
|
|
|
|
|
def shift_range(rng, axis, idx, n, delete):
|
|
"""Shift an A1 range string (no sheet prefix). None = fully deleted."""
|
|
min_col, min_row, max_col, max_row = range_boundaries(rng)
|
|
if axis == "rows":
|
|
span = shift_span(min_row, max_row, idx, n, delete)
|
|
if span is None:
|
|
return None
|
|
min_row, max_row = span
|
|
else:
|
|
span = shift_span(min_col, max_col, idx, n, delete)
|
|
if span is None:
|
|
return None
|
|
min_col, max_col = span
|
|
start = f"{get_column_letter(min_col)}{min_row}"
|
|
end = f"{get_column_letter(max_col)}{max_row}"
|
|
return start if start == end and ":" not in rng else f"{start}:{end}"
|
|
|
|
|
|
class RefRewriter:
|
|
"""Rewrite A1 references in formula-like text for one shift op."""
|
|
|
|
def __init__(self, target_sheet, axis, idx, n, delete):
|
|
self.target = target_sheet.lower()
|
|
self.axis, self.idx, self.n, self.delete = axis, idx, n, delete
|
|
|
|
def _shift_coord(self, coord):
|
|
m = COORD_RE.match(coord)
|
|
col_abs, col, row_abs, row = m.groups()
|
|
ci, ri = column_index_from_string(col.upper()), int(row)
|
|
if self.axis == "rows":
|
|
ri = shift_point(ri, self.idx, self.n, self.delete)
|
|
if ri is None:
|
|
return None
|
|
else:
|
|
ci = shift_point(ci, self.idx, self.n, self.delete)
|
|
if ci is None:
|
|
return None
|
|
return f"{col_abs}{get_column_letter(ci)}{row_abs}{ri}"
|
|
|
|
def _shift_pair(self, start, end):
|
|
"""Shift a range preserving $ flags; None = collapsed to #REF!."""
|
|
new_start = self._shift_coord(start)
|
|
new_end = self._shift_coord(end)
|
|
if new_start is None or new_end is None:
|
|
# spans may survive partial deletion: clamp via span math
|
|
s, e = COORD_RE.match(start), COORD_RE.match(end)
|
|
if self.axis == "rows":
|
|
span = shift_span(int(s.group(4)), int(e.group(4)),
|
|
self.idx, self.n, self.delete)
|
|
if span is None:
|
|
return None
|
|
new_start = f"{s.group(1)}{s.group(2)}{s.group(3)}{span[0]}"
|
|
new_end = f"{e.group(1)}{e.group(2)}{e.group(3)}{span[1]}"
|
|
else:
|
|
span = shift_span(column_index_from_string(s.group(2).upper()),
|
|
column_index_from_string(e.group(2).upper()),
|
|
self.idx, self.n, self.delete)
|
|
if span is None:
|
|
return None
|
|
new_start = (f"{s.group(1)}{get_column_letter(span[0])}"
|
|
f"{s.group(3)}{s.group(4)}")
|
|
new_end = (f"{e.group(1)}{get_column_letter(span[1])}"
|
|
f"{e.group(3)}{e.group(4)}")
|
|
return new_start, new_end
|
|
|
|
def _sub(self, match, home_sheet):
|
|
prefix = match.group("sheet") or ""
|
|
if prefix:
|
|
name = prefix[:-1]
|
|
if name.startswith("'"):
|
|
name = name[1:-1].replace("''", "'")
|
|
ref_sheet = name
|
|
else:
|
|
ref_sheet = home_sheet
|
|
if ref_sheet.lower() != self.target:
|
|
return match.group(0)
|
|
start, end = match.group("start"), match.group("end")
|
|
if end is None:
|
|
new = self._shift_coord(start)
|
|
return prefix + ("#REF!" if new is None else new)
|
|
pair = self._shift_pair(start, end)
|
|
return (prefix + "#REF!" if pair is None
|
|
else f"{prefix}{pair[0]}:{pair[1]}")
|
|
|
|
def rewrite(self, text, home_sheet):
|
|
"""Rewrite refs outside quoted string literals. Returns new text."""
|
|
out, pos = [], 0
|
|
for lit in STRING_RE.finditer(text):
|
|
out.append(REF_RE.sub(lambda m: self._sub(m, home_sheet),
|
|
text[pos:lit.start()]))
|
|
out.append(lit.group(0))
|
|
pos = lit.end()
|
|
out.append(REF_RE.sub(lambda m: self._sub(m, home_sheet), text[pos:]))
|
|
return "".join(out)
|
|
|
|
|
|
def shift_dimensions(dims, idx, n, delete, is_row):
|
|
"""Rebuild a row/column dimensions map with shifted keys."""
|
|
items = list(dims.items())
|
|
saved = {}
|
|
for key, dim in items:
|
|
pos = key if is_row else column_index_from_string(key)
|
|
new = shift_point(pos, idx, n, delete)
|
|
if new is not None and new != pos:
|
|
saved[new if is_row else get_column_letter(new)] = dim
|
|
del dims[key]
|
|
for key, dim in saved.items():
|
|
if is_row:
|
|
dim.index = key
|
|
else:
|
|
dim.index = column_index_from_string(key)
|
|
dims[key] = dim
|
|
return len(saved)
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(
|
|
description="Insert/delete rows or columns AND rewrite formula "
|
|
"references, merges, filters, validations, tables, and "
|
|
"defined names to match.",
|
|
epilog="Cannot shift: chart anchors, images, conditional-format "
|
|
"rule formulas. See references/restructuring.md.")
|
|
ap.add_argument("file", help="path to .xlsx file")
|
|
ap.add_argument("--sheet", help="target sheet (default: active)")
|
|
ap.add_argument("--out", help="output path (default: edit in place)")
|
|
op = ap.add_mutually_exclusive_group(required=True)
|
|
op.add_argument("--insert-rows", metavar="IDX[:N]")
|
|
op.add_argument("--delete-rows", metavar="IDX[:N]")
|
|
op.add_argument("--insert-cols", metavar="COL[:N]",
|
|
help="COL is a letter (B) or 1-based number")
|
|
op.add_argument("--delete-cols", metavar="COL[:N]")
|
|
args = ap.parse_args(argv)
|
|
|
|
raw = (args.insert_rows or args.delete_rows
|
|
or args.insert_cols or args.delete_cols)
|
|
idx_s, _, n_s = raw.partition(":")
|
|
n = int(n_s) if n_s else 1
|
|
axis = "rows" if (args.insert_rows or args.delete_rows) else "cols"
|
|
delete = bool(args.delete_rows or args.delete_cols)
|
|
if axis == "cols" and idx_s.isalpha():
|
|
idx = column_index_from_string(idx_s.upper())
|
|
else:
|
|
idx = int(idx_s)
|
|
|
|
wb = load_workbook(args.file)
|
|
ws = wb[args.sheet] if args.sheet else wb.active
|
|
rewriter = RefRewriter(ws.title, axis, idx, n, delete)
|
|
report = {"ok": True, "sheet": ws.title, "axis": axis,
|
|
"op": "delete" if delete else "insert", "index": idx, "count": n,
|
|
"formulas": [], "merges": [], "tables": {}, "defined_names": {},
|
|
"validations": [], "conditional_formats": [],
|
|
"not_shifted": ["chart anchors", "images",
|
|
"conditional-format rule formulas"]}
|
|
|
|
# 1. capture merge ranges (openpyxl does not move them), then unmerge
|
|
old_merges = [str(r) for r in list(ws.merged_cells.ranges)]
|
|
for rng in old_merges:
|
|
ws.unmerge_cells(rng)
|
|
|
|
# 2. structural move of cell values/styles/comments
|
|
getattr(ws, f"{report['op']}_{axis}")(idx, n)
|
|
|
|
# 3. formulas everywhere
|
|
for sheet in wb.worksheets:
|
|
for row in sheet.iter_rows():
|
|
for cell in row:
|
|
if isinstance(cell.value, str) and cell.value.startswith("="):
|
|
new = rewriter.rewrite(cell.value, sheet.title)
|
|
if new != cell.value:
|
|
report["formulas"].append(
|
|
{"sheet": sheet.title, "cell": cell.coordinate,
|
|
"from": cell.value, "to": new})
|
|
cell.value = new
|
|
|
|
# 4. merges back, shifted
|
|
for rng in old_merges:
|
|
new = shift_range(rng, axis, idx, n, delete)
|
|
if new is None:
|
|
report["merges"].append({"from": rng, "to": None})
|
|
else:
|
|
ws.merge_cells(new)
|
|
if new != rng:
|
|
report["merges"].append({"from": rng, "to": new})
|
|
|
|
# 5. autofilter + freeze panes
|
|
if ws.auto_filter.ref:
|
|
new = shift_range(ws.auto_filter.ref, axis, idx, n, delete)
|
|
if new != ws.auto_filter.ref:
|
|
report["autofilter"] = {"from": ws.auto_filter.ref, "to": new}
|
|
ws.auto_filter.ref = new
|
|
if ws.freeze_panes:
|
|
m = COORD_RE.match(ws.freeze_panes)
|
|
ci = column_index_from_string(m.group(2).upper())
|
|
ri = int(m.group(4))
|
|
if axis == "rows":
|
|
ri = shift_point(ri, idx, n, delete) or max(idx, 2)
|
|
else:
|
|
ci = shift_point(ci, idx, n, delete) or max(idx, 2)
|
|
new = f"{get_column_letter(ci)}{ri}"
|
|
if new != ws.freeze_panes:
|
|
report["freeze_panes"] = {"from": ws.freeze_panes, "to": new}
|
|
ws.freeze_panes = new
|
|
|
|
# 6. data validations + conditional formatting applied ranges
|
|
for dv in ws.data_validations.dataValidation:
|
|
old = str(dv.sqref)
|
|
parts = [shift_range(p, axis, idx, n, delete) for p in old.split()]
|
|
parts = [p for p in parts if p]
|
|
if parts and " ".join(parts) != old:
|
|
dv.sqref = " ".join(parts)
|
|
report["validations"].append({"from": old, "to": str(dv.sqref)})
|
|
new_cf = ConditionalFormattingList()
|
|
for cf in ws.conditional_formatting:
|
|
old = str(cf.sqref)
|
|
parts = [shift_range(p, axis, idx, n, delete) for p in old.split()]
|
|
parts = [p for p in parts if p]
|
|
if not parts:
|
|
report["conditional_formats"].append({"from": old, "to": None})
|
|
continue
|
|
new = " ".join(parts)
|
|
for rule in cf.rules:
|
|
new_cf.add(new, rule)
|
|
if new != old:
|
|
report["conditional_formats"].append({"from": old, "to": new})
|
|
ws.conditional_formatting = new_cf
|
|
|
|
# 7. native tables on the edited sheet
|
|
for table in ws.tables.values():
|
|
new = shift_range(table.ref, axis, idx, n, delete)
|
|
if new and new != table.ref:
|
|
report["tables"][table.displayName] = {"from": table.ref,
|
|
"to": new}
|
|
table.ref = new
|
|
|
|
# 8. workbook-scope defined names
|
|
for name, dn in wb.defined_names.items():
|
|
if dn.attr_text and "!" in dn.attr_text:
|
|
new = rewriter.rewrite(dn.attr_text, ws.title)
|
|
if new != dn.attr_text:
|
|
report["defined_names"][name] = {"from": dn.attr_text,
|
|
"to": new}
|
|
dn.attr_text = new
|
|
|
|
# 9. row heights / column widths
|
|
if axis == "rows":
|
|
shift_dimensions(ws.row_dimensions, idx, n, delete, is_row=True)
|
|
else:
|
|
shift_dimensions(ws.column_dimensions, idx, n, delete, is_row=False)
|
|
|
|
out = args.out or args.file
|
|
wb.save(out)
|
|
report["output"] = out
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except Exception as exc: # noqa: BLE001
|
|
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
|
|
sys.exit(1)
|