From 0e159ef52446d9e9e42b4d407d0be3108354fc9e Mon Sep 17 00:00:00 2001 From: DanielL Date: Mon, 27 Apr 2026 18:18:18 +0200 Subject: [PATCH 1/4] Add samsung-notes CLI, GRBL G-code export, and packaging - Add extract_to_dict() and argparse-based extract CLI with -o/--output - Replace plot_strokes subprocess with direct extract_to_dict import - Add sdocx_gcode: px-to-mm scaling, Z pen lift profile, multi-page/M0 - Add samsung-notes entry point: extract, gcode, inbox batch - Bundle default profile samsung_notes_profiles/grbl_plotter_z.toml - Document pip install -e . and commands in README - Ignore outbox, inbox, build, and egg-info --- .gitignore | 10 + README.md | 49 ++++- cli.py | 239 ++++++++++++++++++++ plot_strokes.py | 19 +- pyproject.toml | 21 ++ samsung_notes_profiles/__init__.py | 1 + samsung_notes_profiles/grbl_plotter_z.toml | 17 ++ sdocx_extractor.py | 73 +++--- sdocx_gcode.py | 245 +++++++++++++++++++++ 9 files changed, 625 insertions(+), 49 deletions(-) create mode 100644 cli.py create mode 100644 pyproject.toml create mode 100644 samsung_notes_profiles/__init__.py create mode 100644 samsung_notes_profiles/grbl_plotter_z.toml create mode 100644 sdocx_gcode.py diff --git a/.gitignore b/.gitignore index 175d5de..ede06e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ decompiled_source com.samsung.android.app.notes_4.4.37.13-443713003_minAPI26(arm64-v8a)(nodpi)_apkmirror.com.apk # Python +.venv/ __pycache__/ *.pyc .ruff_cache/ @@ -15,6 +16,15 @@ output.json *.svg !Screenshot_*.jpg +# Pipeline (optional local folders) +outbox/ +inbox/ + +# Packaging / local install +build/ +dist/ +*.egg-info/ + # Temporary folders zippedFiles/ notePnote/ diff --git a/README.md b/README.md index d1e137b..31aeaad 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,44 @@ This project documents the reverse engineering of Samsung Notes `.sdocx` files a ## Quick Start +### Install (editable, Python 3.11+) + ```bash -# Extract strokes to JSON -python3 sdocx_extractor.py +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e . +``` + +### Unified CLI (`samsung-notes`) + +```bash +# Extract to stdout or file +samsung-notes extract note.sdocx +samsung-notes extract note.sdocx -o extracted.json + +# GRBL G-code (plotter: pen up/down via Z). Default profile: bundled `grbl_plotter_z.toml` +samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 --flip-y + +# From saved JSON +samsung-notes gcode --from-json extracted.json -o plot.gcode --width-mm 120 + +# Batch: every `inbox/*.sdocx` → `outbox/.gcode` (and optional SVG) +mkdir -p inbox outbox +cp mynote.sdocx inbox/ +samsung-notes inbox --also-svg +``` + +Copy and edit **[`samsung_notes_profiles/grbl_plotter_z.toml`](samsung_notes_profiles/grbl_plotter_z.toml)** for your machine, then pass `--profile /path/to/your.toml`. + +### Scripts without install + +```bash +# Extract strokes to JSON (optional -o/--output file) +python3 sdocx_extractor.py note.sdocx +python3 sdocx_extractor.py note.sdocx -o extracted.json -# Generate SVG visualization -python3 sdocx_extractor.py file.sdocx | python3 -c " -import json, sys -data = json.load(sys.stdin) -# ... generates output_strokes.svg -" +# SVG preview (first page) +python3 plot_strokes.py note.sdocx --output-dir ./out ``` --- @@ -347,6 +375,10 @@ python3 plot_strokes.py |------|---------| | `sdocx_extractor.py` | Main extraction tool | | `plot_strokes.py` | Generate SVG/PNG visualizations | +| `sdocx_gcode.py` | SDOCX/JSON → GRBL G-code (plotter Z) | +| `cli.py` | `samsung-notes` CLI (`extract`, `gcode`, `inbox`) | +| `pyproject.toml` | Package metadata and entry points | +| `samsung_notes_profiles/` | Bundled machine profiles (TOML) | | `REVERSE_ENGINEERING_WORKFLOW.md` | Detailed RE methodology | | `sdocxFiles/` | Test SDOCX files | | `decompiled_source/` | Decompiled SDK and native libraries | @@ -358,6 +390,7 @@ python3 plot_strokes.py - [x] Parse stroke point data from binary - [x] Accurate coordinate extraction (5.5 fixed-point) - [x] SVG export +- [x] GRBL G-code export (plotter, Z pen lift; `samsung-notes gcode`) - [ ] Implement `.spi` file parsing for rendered strokes - [ ] Add `mediaInfo.dat` parser - [ ] Support text box content extraction diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..422cace --- /dev/null +++ b/cli.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Unified CLI: extract, gcode, inbox.""" + +from __future__ import annotations + +import argparse +import json +import sys +import traceback +from pathlib import Path + +from sdocx_extractor import extract_to_dict +from sdocx_gcode import ( + default_profile_path, + load_extracted_json, + write_gcode_outputs, +) +from plot_strokes import generate_svg, get_output_path + + +def cmd_extract(args: argparse.Namespace) -> int: + if not Path(args.sdocx).is_file(): + print(f"Error: not a file: {args.sdocx}", file=sys.stderr) + return 1 + try: + data = extract_to_dict(args.sdocx) + text = json.dumps(data, indent=2, ensure_ascii=False) + if args.output: + Path(args.output).write_text(text, encoding="utf-8") + else: + print(text) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + traceback.print_exc() + return 1 + return 0 + + +def _load_gcode_input(args: argparse.Namespace) -> dict: + if args.from_json: + jp = Path(args.from_json) + if not jp.is_file(): + raise SystemExit(f"Not a file: {jp}") + return load_extracted_json(jp) + if not args.sdocx: + raise SystemExit("Provide a .sdocx path or --from-json") + if not Path(args.sdocx).is_file(): + raise SystemExit(f"Not a file: {args.sdocx}") + return extract_to_dict(args.sdocx) + + +def cmd_gcode(args: argparse.Namespace) -> int: + try: + data = _load_gcode_input(args) + except SystemExit as e: + print(e, file=sys.stderr) + return 1 + + profile = Path(args.profile) if args.profile else default_profile_path() + if not profile.is_file(): + print(f"Error: profile not found: {profile}", file=sys.stderr) + return 1 + + out = Path(args.out) + try: + paths = write_gcode_outputs( + data, + profile, + out, + width_mm=args.width_mm, + flip_y=args.flip_y, + offset_x_mm=args.offset_x, + offset_y_mm=args.offset_y, + page_pause_m0=not args.no_page_pause, + one_file_per_page=args.one_file_per_page, + ) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + traceback.print_exc() + return 1 + + for p in paths: + print(p) + return 0 + + +def cmd_inbox(args: argparse.Namespace) -> int: + inbox_dir = Path(args.inbox_dir) + outbox_dir = Path(args.outbox_dir) + err_dir = outbox_dir / "errors" + outbox_dir.mkdir(parents=True, exist_ok=True) + err_dir.mkdir(parents=True, exist_ok=True) + + files = sorted(inbox_dir.glob("*.sdocx")) + if not files: + print(f"No .sdocx files in {inbox_dir}", file=sys.stderr) + return 1 + + profile = Path(args.profile) if args.profile else default_profile_path() + ok = 0 + for sdocx in files: + stem = sdocx.stem + log_lines: list[str] = [] + try: + data = extract_to_dict(str(sdocx)) + out_g = outbox_dir / f"{stem}.gcode" + paths = write_gcode_outputs( + data, + profile, + out_g, + width_mm=args.width_mm, + flip_y=args.flip_y, + offset_x_mm=args.offset_x, + offset_y_mm=args.offset_y, + page_pause_m0=not args.no_page_pause, + one_file_per_page=args.one_file_per_page, + ) + log_lines.append("OK gcode -> " + ", ".join(str(p) for p in paths)) + if args.also_svg: + svg_path = get_output_path(str(sdocx), str(outbox_dir), ".svg") + if generate_svg(data, svg_path, show_bbox=not args.no_bbox): + log_lines.append(f"OK svg -> {svg_path}") + else: + log_lines.append("WARN: svg generation failed") + ok += 1 + except Exception as e: + log_lines.append(f"FAIL: {e}") + traceback.print_exc() + (err_dir / f"{stem}.log").write_text( + "\n".join(log_lines) + "\n\n" + traceback.format_exc(), + encoding="utf-8", + ) + continue + (outbox_dir / f"{stem}.log").write_text( + "\n".join(log_lines) + "\n", encoding="utf-8" + ) + + print(f"Processed {ok}/{len(files)} file(s) into {outbox_dir}") + return 0 if ok == len(files) else 1 + + +def _add_gcode_flags(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--profile", + type=str, + default=None, + help="Machine profile TOML (default: bundled grbl_plotter_z.toml)", + ) + p.add_argument( + "--width-mm", + type=float, + default=120.0, + help="Target drawing width in mm (height scales, default 120)", + ) + p.add_argument( + "--flip-y", + action="store_true", + help="Flip Y (screen top-left vs machine origin)", + ) + p.add_argument("--offset-x", type=float, default=0.0, help="X offset in mm") + p.add_argument("--offset-y", type=float, default=0.0, help="Y offset in mm") + p.add_argument( + "--no-page-pause", + action="store_true", + help="Do not insert M0 between pages (combined file only)", + ) + p.add_argument( + "--one-file-per-page", + action="store_true", + help="Write stem_p01.gcode, stem_p02.gcode, ...", + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="samsung-notes", + description="Samsung Notes .sdocx: extract JSON, GRBL G-code, inbox batch", + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_ex = sub.add_parser("extract", help="Extract .sdocx to JSON") + p_ex.add_argument("sdocx", help="Path to .sdocx") + p_ex.add_argument("-o", "--output", help="Write JSON to file instead of stdout") + p_ex.set_defaults(func=cmd_extract) + + p_gc = sub.add_parser("gcode", help="Generate GRBL G-code from .sdocx or JSON") + p_gc.add_argument( + "sdocx", + nargs="?", + default=None, + help="Path to .sdocx (omit if using --from-json)", + ) + p_gc.add_argument( + "--from-json", + metavar="FILE", + help="Use extracted JSON instead of parsing .sdocx", + ) + p_gc.add_argument( + "-o", + "--out", + required=True, + help="Output .gcode path (or basename when --one-file-per-page)", + ) + _add_gcode_flags(p_gc) + p_gc.set_defaults(func=cmd_gcode) + + p_in = sub.add_parser( + "inbox", + help="Convert all inbox/*.sdocx to outbox/*.gcode", + ) + p_in.add_argument( + "--inbox-dir", + default="inbox", + help="Directory containing .sdocx (default: inbox)", + ) + p_in.add_argument( + "--outbox-dir", + default="outbox", + help="Output directory (default: outbox)", + ) + p_in.add_argument( + "--also-svg", + action="store_true", + help="Also write preview SVG per file", + ) + p_in.add_argument( + "--no-bbox", + action="store_true", + help="With --also-svg: do not draw bounding boxes", + ) + _add_gcode_flags(p_in) + p_in.set_defaults(func=cmd_inbox) + + args = parser.parse_args() + sys.exit(args.func(args)) + + +if __name__ == "__main__": + main() diff --git a/plot_strokes.py b/plot_strokes.py index 38b658d..ef108d1 100644 --- a/plot_strokes.py +++ b/plot_strokes.py @@ -18,8 +18,6 @@ python3 plot_strokes.py --output-dir=output/ --all # Output to specific directory """ -import json -import subprocess import sys import os import argparse @@ -30,17 +28,14 @@ def extract_strokes(sdocx_path: str) -> Optional[dict]: - """Run sdocx_extractor.py and return parsed JSON.""" - script_dir = os.path.dirname(os.path.abspath(__file__)) or "." - result = subprocess.run( - ["python3", os.path.join(script_dir, "sdocx_extractor.py"), sdocx_path], - capture_output=True, - text=True, - ) - if result.returncode != 0: - print(f"Error extracting {sdocx_path}: {result.stderr}", file=sys.stderr) + """Parse .sdocx and return extracted data dict.""" + try: + from sdocx_extractor import extract_to_dict + + return extract_to_dict(sdocx_path) + except Exception as e: + print(f"Error extracting {sdocx_path}: {e}", file=sys.stderr) return None - return json.loads(result.stdout) def iter_objects(objects: list): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2820dff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "samsung-notes-format" +version = "0.1.0" +description = "Extract Samsung Notes .sdocx strokes and export GRBL G-code" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +samsung-notes = "cli:main" + +[tool.setuptools] +packages = ["samsung_notes_profiles"] +py-modules = ["sdocx_extractor", "plot_strokes", "sdocx_gcode", "cli"] + +[tool.setuptools.package-data] +samsung_notes_profiles = ["*.toml"] diff --git a/samsung_notes_profiles/__init__.py b/samsung_notes_profiles/__init__.py new file mode 100644 index 0000000..552ea5b --- /dev/null +++ b/samsung_notes_profiles/__init__.py @@ -0,0 +1 @@ +# Bundled machine profiles (TOML) for G-code export. diff --git a/samsung_notes_profiles/grbl_plotter_z.toml b/samsung_notes_profiles/grbl_plotter_z.toml new file mode 100644 index 0000000..3be65e8 --- /dev/null +++ b/samsung_notes_profiles/grbl_plotter_z.toml @@ -0,0 +1,17 @@ +# Default GRBL plotter profile: pen up/down via Z (stepper Z). + +preamble = """ +G21 +G90 +G94 +""" + +pen_up_z = 5.0 +pen_down_z = 0.0 +travel_feed = 3000 +draw_feed = 1200 +z_feed = 600 + +postamble = """ +G0 Z5.000 +""" diff --git a/sdocx_extractor.py b/sdocx_extractor.py index 0ab829b..97e9473 100644 --- a/sdocx_extractor.py +++ b/sdocx_extractor.py @@ -27,6 +27,7 @@ - libSPenModel.so (sm_RestoreStroke, sm_ShortToFloatDelta) """ +import argparse import struct import zipfile import json @@ -697,38 +698,52 @@ def count_strokes_in_list(objects: List[ObjectData]) -> int: return count -def main(): - if len(sys.argv) < 2: - print("Usage: python sdocx_extractor.py ") - sys.exit(1) - - filepath = sys.argv[1] - if not os.path.exists(filepath): - print(f"Error: File not found: {filepath}") +def extract_to_dict(filepath: str) -> dict: + """Parse .sdocx and return the same structure as JSON export (no I/O).""" + extractor = SDOCXExtractor(filepath) + data = extractor.extract() + return { + "metadata": asdict(data.metadata), + "page_ids": data.page_ids, + "pages": [asdict(p) for p in data.pages], + "media_files": data.media_files, + "summary": { + "title": data.metadata.title, + "page_count": len(data.pages), + "total_layers": sum(len(p.layers) for p in data.pages), + "total_objects": sum( + sum(len(l.objects) for l in p.layers) for p in data.pages + ), + "stroke_count": count_strokes(data), + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Extract Samsung Notes .sdocx to JSON (strokes, metadata)." + ) + parser.add_argument("sdocx", help="Path to .sdocx file") + parser.add_argument( + "-o", + "--output", + metavar="FILE", + help="Write JSON to this file instead of stdout", + ) + args = parser.parse_args() + + if not os.path.exists(args.sdocx): + print(f"Error: File not found: {args.sdocx}", file=sys.stderr) sys.exit(1) try: - extractor = SDOCXExtractor(filepath) - data = extractor.extract() - - output = { - "metadata": asdict(data.metadata), - "page_ids": data.page_ids, - "pages": [asdict(p) for p in data.pages], - "media_files": data.media_files, - "summary": { - "title": data.metadata.title, - "page_count": len(data.pages), - "total_layers": sum(len(p.layers) for p in data.pages), - "total_objects": sum( - sum(len(l.objects) for l in p.layers) for p in data.pages - ), - "stroke_count": count_strokes(data), - }, - } - - print(json.dumps(output, indent=2, ensure_ascii=False)) - + output = extract_to_dict(args.sdocx) + text = json.dumps(output, indent=2, ensure_ascii=False) + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(text) + else: + print(text) except Exception as e: print(f"Error: {e}", file=sys.stderr) import traceback diff --git a/sdocx_gcode.py b/sdocx_gcode.py new file mode 100644 index 0000000..6cbf3ff --- /dev/null +++ b/sdocx_gcode.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Samsung Notes extracted JSON / .sdocx → GRBL G-code (plotter, Z pen lift).""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path +from typing import Any + +from plot_strokes import iter_objects + + +def default_profile_path() -> Path: + import importlib.resources as ir + + return Path(str(ir.files("samsung_notes_profiles") / "grbl_plotter_z.toml")) + + +def _nonempty_lines(block: str) -> list[str]: + lines: list[str] = [] + for line in (block or "").splitlines(): + s = line.strip() + if s: + lines.append(s) + return lines + + +def load_profile(path: Path) -> dict[str, Any]: + with path.open("rb") as f: + raw = tomllib.load(f) + travel = float(raw["travel_feed"]) + return { + "preamble_lines": _nonempty_lines(str(raw.get("preamble", ""))), + "postamble_lines": _nonempty_lines(str(raw.get("postamble", ""))), + "pen_up_z": float(raw["pen_up_z"]), + "pen_down_z": float(raw["pen_down_z"]), + "travel_feed": travel, + "draw_feed": float(raw["draw_feed"]), + "z_feed": float(raw.get("z_feed", travel)), + } + + +def load_extracted_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as f: + return json.load(f) + + +def _px_to_mm( + x_px: float, + y_px: float, + page_w: int, + page_h: int, + width_mm: float, + flip_y: bool, + offset_x_mm: float, + offset_y_mm: float, +) -> tuple[float, float]: + scale = width_mm / max(1, page_w) + x_mm = offset_x_mm + x_px * scale + if flip_y: + y_mm = offset_y_mm + (page_h - y_px) * scale + else: + y_mm = offset_y_mm + y_px * scale + return x_mm, y_mm + + +def collect_strokes_mm_for_page( + page: dict[str, Any], + width_mm: float, + flip_y: bool, + offset_x_mm: float, + offset_y_mm: float, +) -> list[list[tuple[float, float]]]: + pw = max(1, int(page.get("width") or 1)) + ph = max(1, int(page.get("height") or 1)) + out: list[list[tuple[float, float]]] = [] + for layer in page.get("layers") or []: + for obj in iter_objects(layer.get("objects") or []): + if obj.get("object_type") not in (1, 15): + continue + pts = obj.get("stroke_points") or [] + if len(pts) < 2: + continue + poly: list[tuple[float, float]] = [] + for p in pts: + poly.append( + _px_to_mm( + float(p[0]), + float(p[1]), + pw, + ph, + width_mm, + flip_y, + offset_x_mm, + offset_y_mm, + ) + ) + out.append(poly) + return out + + +def _emit_strokes( + strokes: list[list[tuple[float, float]]], profile: dict[str, Any] +) -> list[str]: + z_up = profile["pen_up_z"] + z_down = profile["pen_down_z"] + f_travel = profile["travel_feed"] + f_draw = profile["draw_feed"] + f_z = profile["z_feed"] + lines: list[str] = [] + + for poly in strokes: + x0, y0 = poly[0] + lines.append(f"G0 Z{z_up:.3f} F{f_z:.0f}") + lines.append(f"G0 X{x0:.3f} Y{y0:.3f} F{f_travel:.0f}") + lines.append(f"G1 Z{z_down:.3f} F{f_z:.0f}") + for x, y in poly[1:]: + lines.append(f"G1 X{x:.3f} Y{y:.3f} F{f_draw:.0f}") + + lines.append(f"G0 Z{z_up:.3f} F{f_z:.0f}") + return lines + + +def build_page_gcode( + page: dict[str, Any], + page_index: int, + page_count: int, + profile: dict[str, Any], + width_mm: float, + flip_y: bool, + offset_x_mm: float, + offset_y_mm: float, + include_preamble: bool, + include_postamble: bool, +) -> str: + pw = max(1, int(page.get("width") or 1)) + ph = max(1, int(page.get("height") or 1)) + strokes = collect_strokes_mm_for_page( + page, width_mm, flip_y, offset_x_mm, offset_y_mm + ) + chunks: list[str] = [] + chunks.append(f"( page {page_index + 1} / {page_count} )") + chunks.append(f"( page pixels {pw} x {ph}, target width {width_mm:.3f} mm )") + chunks.append(f"( strokes {len(strokes)} )") + + body_lines: list[str] = [] + if include_preamble: + body_lines.extend(profile["preamble_lines"]) + body_lines.extend(_emit_strokes(strokes, profile)) + if include_postamble: + body_lines.extend(profile["postamble_lines"]) + + return "\n".join(chunks) + "\n" + "\n".join(body_lines) + "\n" + + +def build_combined_gcode( + data: dict[str, Any], + profile: dict[str, Any], + width_mm: float, + flip_y: bool, + offset_x_mm: float, + offset_y_mm: float, + page_pause_m0: bool, +) -> str: + pages = data.get("pages") or [] + if not pages: + return "" + + n = len(pages) + parts: list[str] = [] + parts.extend(profile["preamble_lines"]) + parts.append("") + + for i, page in enumerate(pages): + pw = max(1, int(page.get("width") or 1)) + ph = max(1, int(page.get("height") or 1)) + strokes = collect_strokes_mm_for_page( + page, width_mm, flip_y, offset_x_mm, offset_y_mm + ) + parts.append(f"( page {i + 1} / {n} )") + parts.append(f"( page pixels {pw} x {ph}, target width {width_mm:.3f} mm )") + parts.append(f"( strokes {len(strokes)} )") + parts.append("") + parts.extend(_emit_strokes(strokes, profile)) + parts.append("") + if page_pause_m0 and i < n - 1: + parts.append("M0 ( pause — next page; resume in sender )") + parts.append("") + + parts.extend(profile["postamble_lines"]) + return "\n".join(parts) + "\n" + + +def write_gcode_outputs( + data: dict[str, Any], + profile_path: Path, + out_path: Path, + *, + width_mm: float, + flip_y: bool, + offset_x_mm: float, + offset_y_mm: float, + page_pause_m0: bool, + one_file_per_page: bool, +) -> list[Path]: + profile = load_profile(profile_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + + if one_file_per_page: + pages = data.get("pages") or [] + n = len(pages) + stem = out_path.stem + parent = out_path.parent + suffix = out_path.suffix or ".gcode" + for i, _page in enumerate(pages): + chunk_path = parent / f"{stem}_p{i + 1:02d}{suffix}" + text = build_page_gcode( + pages[i], + i, + n, + profile, + width_mm, + flip_y, + offset_x_mm, + offset_y_mm, + include_preamble=True, + include_postamble=True, + ) + chunk_path.write_text(text, encoding="utf-8") + written.append(chunk_path) + else: + text = build_combined_gcode( + data, + profile, + width_mm, + flip_y, + offset_x_mm, + offset_y_mm, + page_pause_m0, + ) + out_path.write_text(text, encoding="utf-8") + written.append(out_path) + return written From e07e3b73b6f80af242bb73d1c74b635992103b09 Mon Sep 17 00:00:00 2001 From: DanielL Date: Mon, 27 Apr 2026 18:39:08 +0200 Subject: [PATCH 2/4] Improve writing-order: cluster strokes into rows; default flip-Y on - Replace global (ymin, xmin) sort with ymin-sweep row bands and xmin within row - Add --flip-y/--no-flip-y with default mirror for page-to-machine Y - Update README and CLI help for writing-order and flip defaults --- README.md | 8 ++- cli.py | 12 +++- sdocx_gcode.py | 155 +++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 147 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 31aeaad..ca4b538 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,13 @@ samsung-notes extract note.sdocx samsung-notes extract note.sdocx -o extracted.json # GRBL G-code (plotter: pen up/down via Z). Default profile: bundled `grbl_plotter_z.toml` -samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 --flip-y +samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 + +# Y mirror is on by default (page top → typical plotter orientation). Turn off if needed: +# samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 --no-flip-y + +# Reading order: strokes grouped into horizontal rows, then left→right within each row +samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 --writing-order # From saved JSON samsung-notes gcode --from-json extracted.json -o plot.gcode --width-mm 120 diff --git a/cli.py b/cli.py index 422cace..2277347 100644 --- a/cli.py +++ b/cli.py @@ -73,6 +73,7 @@ def cmd_gcode(args: argparse.Namespace) -> int: offset_y_mm=args.offset_y, page_pause_m0=not args.no_page_pause, one_file_per_page=args.one_file_per_page, + writing_order=args.writing_order, ) except Exception as e: print(f"Error: {e}", file=sys.stderr) @@ -114,6 +115,7 @@ def cmd_inbox(args: argparse.Namespace) -> int: offset_y_mm=args.offset_y, page_pause_m0=not args.no_page_pause, one_file_per_page=args.one_file_per_page, + writing_order=args.writing_order, ) log_lines.append("OK gcode -> " + ", ".join(str(p) for p in paths)) if args.also_svg: @@ -154,8 +156,9 @@ def _add_gcode_flags(p: argparse.ArgumentParser) -> None: ) p.add_argument( "--flip-y", - action="store_true", - help="Flip Y (screen top-left vs machine origin)", + action=argparse.BooleanOptionalAction, + default=True, + help="Mirror page Y when converting to mm (default: on; use --no-flip-y if the plot is upside down)", ) p.add_argument("--offset-x", type=float, default=0.0, help="X offset in mm") p.add_argument("--offset-y", type=float, default=0.0, help="Y offset in mm") @@ -169,6 +172,11 @@ def _add_gcode_flags(p: argparse.ArgumentParser) -> None: action="store_true", help="Write stem_p01.gcode, stem_p02.gcode, ...", ) + p.add_argument( + "--writing-order", + action="store_true", + help="Group strokes into text rows, then left→right per row; orient each path L→R (or top→bottom if vertical)", + ) def main() -> None: diff --git a/sdocx_gcode.py b/sdocx_gcode.py index 6cbf3ff..745d402 100644 --- a/sdocx_gcode.py +++ b/sdocx_gcode.py @@ -65,38 +65,124 @@ def _px_to_mm( return x_mm, y_mm +def _bbox_px(poly: list[tuple[float, float]]) -> tuple[float, float, float, float]: + xs = [p[0] for p in poly] + ys = [p[1] for p in poly] + return min(xs), min(ys), max(xs), max(ys) + + +def _orient_poly_left_to_right( + poly: list[tuple[float, float]], +) -> list[tuple[float, float]]: + """Prefer stroke start left of end; nearly vertical → top-to-bottom in page px.""" + if len(poly) < 2: + return poly + x0, y0 = poly[0] + x1, y1 = poly[-1] + eps = 1e-6 + if abs(x1 - x0) > eps: + if x1 < x0: + return list(reversed(poly)) + return poly + if y1 < y0: + return list(reversed(poly)) + return poly + + +def order_strokes_writing_mode( + polys_px: list[list[tuple[float, float]]], +) -> list[list[tuple[float, float]]]: + """Group strokes into horizontal rows, then left-to-right within each row. + + Rows are built by sorting bboxes by top (ymin) and merging strokes whose top + lies within the current band (same text line / overlapping vertical extent). + This avoids a single global (y, x) sort, which interleaves strokes from + different lines when their ymin differ slightly. + """ + if not polys_px: + return [] + items: list[tuple[tuple[float, float, float, float], list[tuple[float, float]]]] = [ + (_bbox_px(p), p) for p in polys_px + ] + heights = [max(1e-3, bb[3] - bb[1]) for bb, _ in items] + med_h = sorted(heights)[len(heights) // 2] + # Max vertical gap between current row bottom and next stroke top to stay on same row + line_gap = max(10.0, med_h * 0.35) + + items.sort(key=lambda t: (t[0][1], t[0][0])) + + rows_out: list[list[tuple[float, float]]] = [] + cur: list[tuple[tuple[float, float, float, float], list[tuple[float, float]]]] = [] + row_maxy = -1e18 + + for bbox, poly in items: + ymin, ymax = bbox[1], bbox[3] + if not cur: + cur = [(bbox, poly)] + row_maxy = ymax + elif ymin <= row_maxy + line_gap: + cur.append((bbox, poly)) + row_maxy = max(row_maxy, ymax) + else: + cur.sort(key=lambda t: t[0][0]) + for _, p in cur: + rows_out.append(_orient_poly_left_to_right(list(p))) + cur = [(bbox, poly)] + row_maxy = ymax + + if cur: + cur.sort(key=lambda t: t[0][0]) + for _, p in cur: + rows_out.append(_orient_poly_left_to_right(list(p))) + + return rows_out + + +def collect_strokes_px_for_page(page: dict[str, Any]) -> list[list[tuple[float, float]]]: + """Stroke polylines in page pixel coordinates (same as JSON stroke_points).""" + out: list[list[tuple[float, float]]] = [] + for layer in page.get("layers") or []: + for obj in iter_objects(layer.get("objects") or []): + if obj.get("object_type") not in (1, 15): + continue + pts = obj.get("stroke_points") or [] + if len(pts) < 2: + continue + out.append([(float(p[0]), float(p[1])) for p in pts]) + return out + + def collect_strokes_mm_for_page( page: dict[str, Any], width_mm: float, flip_y: bool, offset_x_mm: float, offset_y_mm: float, + *, + writing_order: bool = False, ) -> list[list[tuple[float, float]]]: pw = max(1, int(page.get("width") or 1)) ph = max(1, int(page.get("height") or 1)) + polys_px = collect_strokes_px_for_page(page) + if writing_order: + polys_px = order_strokes_writing_mode(polys_px) out: list[list[tuple[float, float]]] = [] - for layer in page.get("layers") or []: - for obj in iter_objects(layer.get("objects") or []): - if obj.get("object_type") not in (1, 15): - continue - pts = obj.get("stroke_points") or [] - if len(pts) < 2: - continue - poly: list[tuple[float, float]] = [] - for p in pts: - poly.append( - _px_to_mm( - float(p[0]), - float(p[1]), - pw, - ph, - width_mm, - flip_y, - offset_x_mm, - offset_y_mm, - ) + for poly in polys_px: + out.append( + [ + _px_to_mm( + x_px, + y_px, + pw, + ph, + width_mm, + flip_y, + offset_x_mm, + offset_y_mm, ) - out.append(poly) + for x_px, y_px in poly + ] + ) return out @@ -133,16 +219,24 @@ def build_page_gcode( offset_y_mm: float, include_preamble: bool, include_postamble: bool, + *, + writing_order: bool = False, ) -> str: pw = max(1, int(page.get("width") or 1)) ph = max(1, int(page.get("height") or 1)) strokes = collect_strokes_mm_for_page( - page, width_mm, flip_y, offset_x_mm, offset_y_mm + page, + width_mm, + flip_y, + offset_x_mm, + offset_y_mm, + writing_order=writing_order, ) chunks: list[str] = [] chunks.append(f"( page {page_index + 1} / {page_count} )") chunks.append(f"( page pixels {pw} x {ph}, target width {width_mm:.3f} mm )") - chunks.append(f"( strokes {len(strokes)} )") + wo = " writing-order" if writing_order else "" + chunks.append(f"( strokes {len(strokes)}{wo} )") body_lines: list[str] = [] if include_preamble: @@ -162,6 +256,8 @@ def build_combined_gcode( offset_x_mm: float, offset_y_mm: float, page_pause_m0: bool, + *, + writing_order: bool = False, ) -> str: pages = data.get("pages") or [] if not pages: @@ -176,11 +272,17 @@ def build_combined_gcode( pw = max(1, int(page.get("width") or 1)) ph = max(1, int(page.get("height") or 1)) strokes = collect_strokes_mm_for_page( - page, width_mm, flip_y, offset_x_mm, offset_y_mm + page, + width_mm, + flip_y, + offset_x_mm, + offset_y_mm, + writing_order=writing_order, ) parts.append(f"( page {i + 1} / {n} )") parts.append(f"( page pixels {pw} x {ph}, target width {width_mm:.3f} mm )") - parts.append(f"( strokes {len(strokes)} )") + wo = " writing-order" if writing_order else "" + parts.append(f"( strokes {len(strokes)}{wo} )") parts.append("") parts.extend(_emit_strokes(strokes, profile)) parts.append("") @@ -203,6 +305,7 @@ def write_gcode_outputs( offset_y_mm: float, page_pause_m0: bool, one_file_per_page: bool, + writing_order: bool = False, ) -> list[Path]: profile = load_profile(profile_path) out_path.parent.mkdir(parents=True, exist_ok=True) @@ -227,6 +330,7 @@ def write_gcode_outputs( offset_y_mm, include_preamble=True, include_postamble=True, + writing_order=writing_order, ) chunk_path.write_text(text, encoding="utf-8") written.append(chunk_path) @@ -239,6 +343,7 @@ def write_gcode_outputs( offset_x_mm, offset_y_mm, page_pause_m0, + writing_order=writing_order, ) out_path.write_text(text, encoding="utf-8") written.append(out_path) From 7fc01504ce2300c932d7d69ab142171551fecf54 Mon Sep 17 00:00:00 2001 From: DanielL Date: Tue, 28 Apr 2026 01:28:36 +0200 Subject: [PATCH 3/4] Stroke order: single row+X-band algorithm, drop legacy modes - Replace global greedy and ruled-rows chain with one order_strokes() path (ruled rows, horizontal bands, local greedy chain, debris per band, carriage Y) - Remove --ruled-rows-order and ruled_rows_order from CLI/API - G-code page comment: "reordered" when stroke reordering is on - Update README and plot_strokes --show-rows help text --- README.md | 5 +- cli.py | 17 +- plot_strokes.py | 76 +++- sdocx_gcode.py | 901 +++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 940 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index ca4b538..b61341b 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 # Y mirror is on by default (page top → typical plotter orientation). Turn off if needed: # samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 --no-flip-y -# Reading order: strokes grouped into horizontal rows, then left→right within each row -samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 --writing-order +# Stroke order is on by default: ruled rows top-to-bottom, then X-bands (word-ish gaps) left-to-right with local greedy chaining; tiny marks attach to the nearest band. Raw JSON order: --no-writing-order +samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 # From saved JSON samsung-notes gcode --from-json extracted.json -o plot.gcode --width-mm 120 @@ -54,6 +54,7 @@ python3 sdocx_extractor.py note.sdocx -o extracted.json # SVG preview (first page) python3 plot_strokes.py note.sdocx --output-dir ./out +python3 plot_strokes.py note.sdocx --output-dir ./out --show-rows # debug row clustering ``` --- diff --git a/cli.py b/cli.py index 2277347..a628a76 100644 --- a/cli.py +++ b/cli.py @@ -120,7 +120,12 @@ def cmd_inbox(args: argparse.Namespace) -> int: log_lines.append("OK gcode -> " + ", ".join(str(p) for p in paths)) if args.also_svg: svg_path = get_output_path(str(sdocx), str(outbox_dir), ".svg") - if generate_svg(data, svg_path, show_bbox=not args.no_bbox): + if generate_svg( + data, + svg_path, + show_bbox=not args.no_bbox, + show_rows=args.also_svg_rows, + ): log_lines.append(f"OK svg -> {svg_path}") else: log_lines.append("WARN: svg generation failed") @@ -174,8 +179,9 @@ def _add_gcode_flags(p: argparse.ArgumentParser) -> None: ) p.add_argument( "--writing-order", - action="store_true", - help="Group strokes into text rows, then left→right per row; orient each path L→R (or top→bottom if vertical)", + action=argparse.BooleanOptionalAction, + default=True, + help="Reorder strokes for the plotter (default: on). Use --no-writing-order for JSON order.", ) @@ -236,6 +242,11 @@ def main() -> None: action="store_true", help="With --also-svg: do not draw bounding boxes", ) + p_in.add_argument( + "--also-svg-rows", + action="store_true", + help="With --also-svg: overlay writing-order row bands (debug)", + ) _add_gcode_flags(p_in) p_in.set_defaults(func=cmd_inbox) diff --git a/plot_strokes.py b/plot_strokes.py index ef108d1..98871cb 100644 --- a/plot_strokes.py +++ b/plot_strokes.py @@ -9,6 +9,7 @@ --all Process all .sdocx files in sdocxFiles/ directory --output-dir Output directory for generated files (default: current dir) --no-bbox Don't draw bounding boxes + --show-rows Overlay ruled-line row bands (debug; same grid as G-code stroke reordering) --stroke-width Stroke width in pixels (default: 2) Examples: @@ -46,12 +47,69 @@ def iter_objects(objects: list): yield from iter_objects(obj["children"]) +def _append_row_debug_overlay( + svg_lines: list[str], + page: dict, + width: float, + height: float, +) -> None: + """Draw bands + dashed lines for ruled-line row clusters (see ``row_cluster_indices_for_page``).""" + try: + from sdocx_gcode import ( + collect_stroke_polys_and_bboxes_px_for_page, + row_cluster_indices_for_page, + uniform_row_pitch_px_for_page, + ) + except ImportError as e: + print(f"Warning: row overlay needs sdocx_gcode: {e}", file=sys.stderr) + return + + clusters = row_cluster_indices_for_page(page) + _, boxes = collect_stroke_polys_and_bboxes_px_for_page(page) + if not clusters: + return + pitch = uniform_row_pitch_px_for_page(page) + colors = [ + "#ff4d00", + "#00a86b", + "#6b2cff", + "#c200c2", + "#0088cc", + "#cc5500", + "#b38f00", + "#006ecd", + ] + svg_lines.append( + ' ' + ) + for ri, idxs in enumerate(clusters): + yc = sum(0.5 * (boxes[i][1] + boxes[i][3]) for i in idxs) / len(idxs) + y0 = yc - 0.5 * pitch + c = colors[ri % len(colors)] + svg_lines.append( + f' ' + ) + svg_lines.append( + f' ' + ) + label_y = min(yc + 4.0, height - 4.0, y0 + pitch - 2.0) + svg_lines.append( + f' r{ri} ({len(idxs)})' + ) + svg_lines.append(" ") + + def generate_svg( data: Optional[dict], output_path: str, show_bbox: bool = True, stroke_width: float = 2.0, stroke_color: Optional[str] = None, + show_rows: bool = False, ) -> bool: """Generate SVG from extracted stroke data. Returns True on success.""" if not data or not data.get("pages"): @@ -72,6 +130,9 @@ def generate_svg( ' ', ] + if show_rows: + _append_row_debug_overlay(svg_lines, page, float(width), float(height)) + # Use single color if specified, otherwise cycle through palette colors = ( ["#222222"] @@ -151,6 +212,7 @@ def process_file( output_dir: str, show_bbox: bool = True, stroke_width: float = 2.0, + show_rows: bool = False, ) -> bool: """Process a single SDOCX file and generate SVG.""" print(f"\nProcessing: {sdocx_path}") @@ -173,7 +235,13 @@ def process_file( # Generate SVG svg_path = get_output_path(sdocx_path, output_dir, ".svg") - if generate_svg(data, svg_path, show_bbox=show_bbox, stroke_width=stroke_width): + if generate_svg( + data, + svg_path, + show_bbox=show_bbox, + stroke_width=stroke_width, + show_rows=show_rows, + ): print(f" SVG: {svg_path}") return True return False @@ -218,6 +286,11 @@ def main(): default=2.0, help="Stroke width in pixels (default: 2)", ) + parser.add_argument( + "--show-rows", + action="store_true", + help="Overlay ruled-line row bands (same grid as G-code stroke reordering)", + ) args = parser.parse_args() @@ -267,6 +340,7 @@ def main(): args.output_dir, show_bbox=not args.no_bbox, stroke_width=args.stroke_width, + show_rows=args.show_rows, ): success_count += 1 diff --git a/sdocx_gcode.py b/sdocx_gcode.py index 745d402..13a85f8 100644 --- a/sdocx_gcode.py +++ b/sdocx_gcode.py @@ -5,11 +5,15 @@ import json import tomllib +from collections import defaultdict from pathlib import Path from typing import Any from plot_strokes import iter_objects +# After fitting the ruled grid, scale pitch (>1 = taller rows, wider SVG bands, fewer Y splits). +RULED_LINE_ROW_HEIGHT_SCALE = 1.15 + def default_profile_path() -> Path: import importlib.resources as ir @@ -89,58 +93,384 @@ def _orient_poly_left_to_right( return poly -def order_strokes_writing_mode( - polys_px: list[list[tuple[float, float]]], -) -> list[list[tuple[float, float]]]: - """Group strokes into horizontal rows, then left-to-right within each row. +class _DSU: + __slots__ = ("p",) + + def __init__(self, n: int) -> None: + self.p = list(range(n)) + + def find(self, x: int) -> int: + p = self.p + while p[x] != x: + p[x] = p[p[x]] + x = p[x] + return x + + def union(self, a: int, b: int) -> None: + pa, pb = self.find(a), self.find(b) + if pa != pb: + self.p[pa] = pb - Rows are built by sorting bboxes by top (ymin) and merging strokes whose top - lies within the current band (same text line / overlapping vertical extent). - This avoids a single global (y, x) sort, which interleaves strokes from - different lines when their ymin differ slightly. + +def _vertical_overlap_px( + ymin_a: float, ymax_a: float, ymin_b: float, ymax_b: float +) -> float: + return max(0.0, min(ymax_a, ymax_b) - max(ymin_a, ymin_b)) + + +def _shrink_y_interval(ymin: float, ymax: float, shrink: float) -> tuple[float, float]: + """Trim top and bottom of a bbox by ``shrink × height`` (for overlap tests only). + + Long ascenders/descenders stay in the full rect for ``cy`` and weak links, but + core overlap ignores thin tails so two tight text lines merge less often. """ - if not polys_px: + h = max(1e-3, ymax - ymin) + d = shrink * h + return ymin + d, ymax - d + + +def _max_gap_split_on_cy( + items: list[tuple[int, float]], + *, + med_h: float, +) -> tuple[list[int], list[int]] | None: + """Split at the largest gap between consecutive bbox mid-Y values (sorted). + + Tight double lines often show a **clear step** in mid-heights between rows; k-means + on cy can mis-assign strokes whose centers sit between the two modes. + + Rejects splits where one side still spans a tall band of mid-Y (ascenders vs body + vs descenders on the **same** ruled line would otherwise become two rows). + """ + if len(items) < 4: + return None + sorted_items = sorted(items, key=lambda t: t[1]) + vals = [c for _, c in sorted_items] + lo, hi = vals[0], vals[-1] + span = hi - lo + if span < 0.42 * med_h or span > 1.18 * med_h: + return None + gaps = [vals[i + 1] - vals[i] for i in range(len(vals) - 1)] + k = max(range(len(gaps)), key=lambda i: gaps[i]) + max_gap = gaps[k] + # Below ~0.22*med_h we skip (avoids splitting one line where letter mid-Ys drift). + if max_gap < 0.22 * med_h: + return None + left = [sorted_items[i][0] for i in range(k + 1)] + right = [sorted_items[i][0] for i in range(k + 1, len(sorted_items))] + if len(left) < 2 or len(right) < 2: + return None + left_vals = [sorted_items[i][1] for i in range(k + 1)] + right_vals = [sorted_items[i][1] for i in range(k + 1, len(sorted_items))] + span_left = max(left_vals) - min(left_vals) + span_right = max(right_vals) - min(right_vals) + # Real adjacent rows: each side is a thin strip in cy. One handwriting line split + # into asc/body leaves a "fat" group — do not cut. + max_sub = 0.55 * med_h + if span_left > max_sub or span_right > max_sub: + return None + return left, right + + +def _is_debris_stroke(bb: tuple[float, float, float, float], med_h: float) -> bool: + """Small punctuation / dots: defer to end of row and chain by nearest start. + + Wide but short strokes (underlines) stay primary so they sort L→R with text. + """ + lf, tp, rt, bt = bb + w = max(1e-3, rt - lf) + h = max(1e-3, bt - tp) + if h >= 0.26 * med_h: + return False + if w >= 0.52 * med_h: + return False + return w < 0.42 * med_h and h < 0.26 * med_h + + +def _dist2_travel_weighted( + px: float, py: float, sx: float, sy: float, *, y_weight: float +) -> float: + dx, dy = sx - px, sy - py + return dx * dx + (y_weight * dy) * (y_weight * dy) + + +def _travel_step_cost( + px: float, + py: float, + sx: float, + sy: float, + *, + y_weight: float, + back_x_weight: float, +) -> float: + """Cost from current pen-up ``(px,py)`` to next pen-down ``(sx,sy)``. + + ``back_x_weight`` penalizes moving left (wasted X on a plotter) while still + allowing small backtracks for cursive. + """ + base = _dist2_travel_weighted(px, py, sx, sy, y_weight=y_weight) + back = max(0.0, px - sx) + return base + back_x_weight * back * back + + +def _greedy_chain_pick_next( + pool: set[int], + oriented: list[list[tuple[float, float]]], + px: float, + py: float, + *, + y_weight: float, + back_x_weight: float, +) -> int: + def key_i(i: int) -> tuple[float, float, float]: + sx, sy = oriented[i][0][0], oriented[i][0][1] + c = _travel_step_cost( + px, py, sx, sy, y_weight=y_weight, back_x_weight=back_x_weight + ) + return (c, sx, sy) + + return min(pool, key=key_i) + + +def _chain_path_length( + order: list[int], + oriented: list[list[tuple[float, float]]], + *, + y_weight: float, + back_x_weight: float, +) -> float: + if len(order) <= 1: + return 0.0 + tot = 0.0 + px, py = oriented[order[0]][-1] + for i in order[1:]: + sx, sy = oriented[i][0][0], oriented[i][0][1] + tot += _travel_step_cost( + px, py, sx, sy, y_weight=y_weight, back_x_weight=back_x_weight + ) + px, py = oriented[i][-1] + return tot + + +def _greedy_chain_best_of_seeds( + indices: list[int], + oriented: list[list[tuple[float, float]]], + bboxes_px: list[tuple[float, float, float, float]], + med_h: float, + *, + y_weight: float, + back_x_weight: float, + max_seed_trials: int = 5, + prefer_start_y: float | None = None, +) -> list[int]: + """Among strokes whose pen-down sits on the left margin, try a few starts; keep lowest travel cost. + + If ``prefer_start_y`` is set (e.g. after carriage return), seeds are ranked closer in Y first + for equal X — mimicking dropping the pen on the next ruled line. + """ + if len(indices) <= 1: + return list(indices) + min_x = min(oriented[i][0][0] for i in indices) + margin = max(3.5, 0.14 * med_h) + + def _seed_sort_key(i: int) -> tuple[float, ...]: + sx, sy = oriented[i][0][0], oriented[i][0][1] + if prefer_start_y is not None: + return (sx, abs(sy - prefer_start_y), sy, bboxes_px[i][0]) + return (sx, sy, bboxes_px[i][0]) + + seeds = sorted( + [i for i in indices if oriented[i][0][0] <= min_x + margin], + key=_seed_sort_key, + )[:max_seed_trials] + best_order: list[int] | None = None + best_len = float("inf") + for seed in seeds: + pool = set(indices) + pool.remove(seed) + px, py = oriented[seed][-1] + tail: list[int] = [] + while pool: + nxt = _greedy_chain_pick_next( + pool, oriented, px, py, + y_weight=y_weight, back_x_weight=back_x_weight, + ) + tail.append(nxt) + pool.remove(nxt) + px, py = oriented[nxt][-1] + cand = [seed] + tail + ln = _chain_path_length(cand, oriented, y_weight=y_weight, back_x_weight=back_x_weight) + if ln < best_len: + best_len, best_order = ln, cand + assert best_order is not None + return best_order + + +def _order_row_indices_minimize_travel( + idxs: list[int], + oriented: list[list[tuple[float, float]]], + bboxes_px: list[tuple[float, float, float, float]], + med_h: float, +) -> list[int]: + """Primary strokes: greedy travel chain with back-X penalty; try several left seeds. + + Tiny debris still follows via NN from the last primary endpoint. + """ + if len(idxs) <= 1: + return list(idxs) + primary = [i for i in idxs if not _is_debris_stroke(bboxes_px[i], med_h)] + debris = [i for i in idxs if _is_debris_stroke(bboxes_px[i], med_h)] + if not primary: + primary = list(idxs) + debris = [] + y_w = 1.35 + # Stronger penalty for pen-up → pen-down that moves left (px space). + back_w = 4.5 + out = _greedy_chain_best_of_seeds( + primary, + oriented, + bboxes_px, + med_h, + y_weight=y_w, + back_x_weight=back_w, + max_seed_trials=5, + prefer_start_y=None, + ) + if not debris: + return out + px, py = oriented[out[-1]][-1] + pool = set(debris) + while pool: + best = _greedy_chain_pick_next( + pool, oriented, px, py, y_weight=y_w, back_x_weight=back_w, + ) + out.append(best) + pool.remove(best) + px, py = oriented[best][-1] + return out + + +def _cluster_row_primaries_by_x_gap( + primary_idxs: list[int], + bboxes_px: list[tuple[float, float, float, float]], + med_h: float, +) -> list[list[int]]: + """Split primary strokes into left-to-right bands (word-ish) by bbox gaps on X.""" + if not primary_idxs: return [] - items: list[tuple[tuple[float, float, float, float], list[tuple[float, float]]]] = [ - (_bbox_px(p), p) for p in polys_px - ] - heights = [max(1e-3, bb[3] - bb[1]) for bb, _ in items] - med_h = sorted(heights)[len(heights) // 2] - # Max vertical gap between current row bottom and next stroke top to stay on same row - line_gap = max(10.0, med_h * 0.35) - - items.sort(key=lambda t: (t[0][1], t[0][0])) - - rows_out: list[list[tuple[float, float]]] = [] - cur: list[tuple[tuple[float, float, float, float], list[tuple[float, float]]]] = [] - row_maxy = -1e18 - - for bbox, poly in items: - ymin, ymax = bbox[1], bbox[3] - if not cur: - cur = [(bbox, poly)] - row_maxy = ymax - elif ymin <= row_maxy + line_gap: - cur.append((bbox, poly)) - row_maxy = max(row_maxy, ymax) + gap_thresh = max(3.0, 0.48 * med_h) + sorted_i = sorted(primary_idxs, key=lambda i: (bboxes_px[i][0], bboxes_px[i][2])) + groups: list[list[int]] = [] + cur = [sorted_i[0]] + cur_r = bboxes_px[sorted_i[0]][2] + for i in sorted_i[1:]: + lf, _, rt, _ = bboxes_px[i] + if lf - cur_r <= gap_thresh: + cur.append(i) + cur_r = max(cur_r, rt) else: - cur.sort(key=lambda t: t[0][0]) - for _, p in cur: - rows_out.append(_orient_poly_left_to_right(list(p))) - cur = [(bbox, poly)] - row_maxy = ymax + groups.append(cur) + cur = [i] + cur_r = rt + groups.append(cur) + return groups - if cur: - cur.sort(key=lambda t: t[0][0]) - for _, p in cur: - rows_out.append(_orient_poly_left_to_right(list(p))) - return rows_out +def _group_x_span( + g: list[int], bboxes_px: list[tuple[float, float, float, float]] +) -> tuple[float, float]: + lf = min(bboxes_px[i][0] for i in g) + rt = max(bboxes_px[i][2] for i in g) + return lf, rt -def collect_strokes_px_for_page(page: dict[str, Any]) -> list[list[tuple[float, float]]]: - """Stroke polylines in page pixel coordinates (same as JSON stroke_points).""" - out: list[list[tuple[float, float]]] = [] +def _assign_debris_to_x_groups( + debris: list[int], + groups: list[list[int]], + bboxes_px: list[tuple[float, float, float, float]], +) -> dict[int, list[int]]: + """Map each X-group index to debris whose center-x sits closest to that group's span.""" + if not debris or not groups: + return {} + spans = [_group_x_span(g, bboxes_px) for g in groups] + by_g: dict[int, list[int]] = {j: [] for j in range(len(groups))} + + def dist_to_span(cx: float, j: int) -> float: + lo, hi = spans[j] + if cx < lo: + return lo - cx + if cx > hi: + return cx - hi + return 0.0 + + for d in debris: + lf, _, rt, _ = bboxes_px[d] + cx = 0.5 * (lf + rt) + best_j = 0 + best_d = float("inf") + for j in range(len(groups)): + dd = dist_to_span(cx, j) + if dd < best_d or (dd == best_d and j < best_j): + best_d, best_j = dd, j + by_g[best_j].append(d) + for j in by_g: + by_g[j].sort(key=lambda i: (bboxes_px[i][0], bboxes_px[i][1])) + return by_g + + +def _chain_debris_from_last_index( + debris: list[int], + oriented: list[list[tuple[float, float]]], + last_idx: int, + *, + y_weight: float, + back_x_weight: float, +) -> list[int]: + if not debris: + return [] + px, py = oriented[last_idx][-1] + pool = set(debris) + out: list[int] = [] + while pool: + best = _greedy_chain_pick_next( + pool, + oriented, + px, + py, + y_weight=y_weight, + back_x_weight=back_x_weight, + ) + out.append(best) + pool.remove(best) + px, py = oriented[best][-1] + return out + + +def _refine_row_clusters_by_cy( + clusters: list[list[int]], + cy: list[float], + med_h: float, +) -> list[list[int]]: + """Split clusters where sorted bbox mid-heights show a dominant vertical gap.""" + out: list[list[int]] = [] + for idxs in clusters: + items = [(i, cy[i]) for i in idxs] + split = _max_gap_split_on_cy(items, med_h=med_h) + if split is None: + out.append(idxs) + else: + a, b = split + out.append(a) + out.append(b) + return out + + +def collect_stroke_polys_and_bboxes_px_for_page( + page: dict[str, Any], +) -> tuple[list[list[tuple[float, float]]], list[tuple[float, float, float, float]]]: + """Stroke polylines and axis-aligned bboxes (same rects as SVG when present).""" + polys: list[list[tuple[float, float]]] = [] + boxes: list[tuple[float, float, float, float]] = [] for layer in page.get("layers") or []: for obj in iter_objects(layer.get("objects") or []): if obj.get("object_type") not in (1, 15): @@ -148,10 +478,475 @@ def collect_strokes_px_for_page(page: dict[str, Any]) -> list[list[tuple[float, pts = obj.get("stroke_points") or [] if len(pts) < 2: continue - out.append([(float(p[0]), float(p[1])) for p in pts]) + poly = [(float(p[0]), float(p[1])) for p in pts] + br = obj.get("bounding_rect") or {} + lf = float(br.get("left", 0.0)) + tp = float(br.get("top", 0.0)) + rt = float(br.get("right", 0.0)) + bt = float(br.get("bottom", 0.0)) + if rt <= lf or bt <= tp: + bb = _bbox_px(poly) + lf, tp, rt, bt = bb[0], bb[1], bb[2], bb[3] + polys.append(poly) + boxes.append((lf, tp, rt, bt)) + return polys, boxes + + +def collect_strokes_px_for_page(page: dict[str, Any]) -> list[list[tuple[float, float]]]: + """Stroke polylines in page pixel coordinates (same as JSON stroke_points).""" + polys, _ = collect_stroke_polys_and_bboxes_px_for_page(page) + return polys + + +def uniform_row_pitch_px_for_page(page: dict[str, Any]) -> float: + """Ruled line spacing (px) for debug bands: same grid as stroke reordering.""" + _, boxes = collect_stroke_polys_and_bboxes_px_for_page(page) + if not boxes: + return 28.0 + heights = [max(1e-3, bb[3] - bb[1]) for bb in boxes] + med_h = sorted(heights)[len(heights) // 2] + y_mid = [_stroke_y_center_bbox_px(bb) for bb in boxes] + pitch, _ = _ruled_pitch_offset_with_row_height_scale(y_mid, med_h) + return float(pitch) + + +def ruled_line_grid_meta_for_page( + page: dict[str, Any], +) -> tuple[float, float, int, int] | None: + """Pitch, offset, min_row_index, max_row_index (inclusive), including empty ruled rows. + + Row centers are ``offset + k * pitch``. Only defined when the page has strokes. + """ + _, boxes = collect_stroke_polys_and_bboxes_px_for_page(page) + if not boxes: + return None + heights = [max(1e-3, bb[3] - bb[1]) for bb in boxes] + med_h = sorted(heights)[len(heights) // 2] + y_mid = [_stroke_y_center_bbox_px(bb) for bb in boxes] + pitch, off = _ruled_pitch_offset_with_row_height_scale(y_mid, med_h) + inv = 1.0 / max(pitch, 1e-6) + rids = [int(round((y - off) * inv)) for y in y_mid] + return float(pitch), float(off), min(rids), max(rids) + + +def _stroke_y_center_bbox_px(bb: tuple[float, float, float, float]) -> float: + """Vertical anchor: center of the stroke's axis-aligned bounding box (page px).""" + top, bottom = bb[1], bb[3] + return 0.5 * (top + bottom) + + +def _grid_alignment_mse(y_mid: list[float], pitch: float, off: float) -> float: + """Mean squared residual to nearest ruled line ``off + k * pitch`` (unnormalized sum).""" + if pitch <= 1e-9: + return float("inf") + inv = 1.0 / pitch + s = 0.0 + for y in y_mid: + k = round((y - off) * inv) + d = y - (off + k * pitch) + s += d * d + return s + + +def _candidate_ruled_pitches_px(sorted_y: list[float], med_h: float) -> list[float]: + """Pitch candidates from body-height ladders, gap distribution, and span / #jumps.""" + # Ruled notebook: spacing not much smaller than one line of writing. + lo = max(22.0, 1.05 * med_h) + hi = min(520.0, 4.2 * med_h) + cand: set[float] = set() + for m in (1.05, 1.08, 1.10, 1.12, 1.15, 1.18, 1.22, 1.28, 1.32, 1.38): + p = med_h * m + if lo <= p <= hi: + cand.add(float(p)) + lg: list[float] = [] + n = len(sorted_y) + if n >= 2: + gaps = [sorted_y[i + 1] - sorted_y[i] for i in range(n - 1)] + sep = 0.38 * med_h + lg = sorted([g for g in gaps if g > sep]) + for g in lg: + if lo <= g <= hi: + cand.add(float(g)) + if len(lg) >= 2: + cand.add(float(lg[len(lg) // 2])) + cand.add(float(lg[len(lg) * 3 // 4])) + cand.add(float(lg[len(lg) // 4])) + if lg: + base = lg[len(lg) // 2] + for scale in (0.92, 0.94, 0.97, 1.0, 1.03, 1.06, 1.09, 1.12): + p = base * scale + if lo <= p <= hi: + cand.add(float(p)) + seed = _estimate_ruled_line_pitch_px(sorted_y, med_h) + cand.add(seed) + for scale in (0.88, 0.91, 0.94, 0.97, 1.0, 1.03, 1.06, 1.10, 1.14): + p = seed * scale + if lo <= p <= hi: + cand.add(float(p)) + if n >= 3 and lg: + span = sorted_y[-1] - sorted_y[0] + for extra in (-1, 0, 1, 2): + denom = max(1, len(lg) + extra) + p = span / float(denom) + if lo <= p <= hi: + cand.add(float(p)) + return sorted(cand) + + +def _best_ruled_pitch_and_offset_px( + y_mid: list[float], + med_h: float, + *, + off_steps: int = 96, +) -> tuple[float, float]: + """Pick ``(pitch, off)`` that best snaps all bbox centers to one ruled grid (page-wide). + + Tries many pitch hypotheses (dense where handwriting repeats line spacing), fits + ``off`` for each, minimizes sum of squared snap residuals, then refines pitch + locally around the winner. + """ + if not y_mid: + return 28.0, 0.0 + if len(y_mid) == 1: + return _estimate_ruled_line_pitch_px(y_mid, med_h), 0.0 + sy = sorted(y_mid) + candidates = _candidate_ruled_pitches_px(sy, med_h) + best_p = _estimate_ruled_line_pitch_px(sy, med_h) + best_o = _best_ruled_grid_offset_px(y_mid, best_p, steps=off_steps) + best_c = _grid_alignment_mse(y_mid, best_p, best_o) + for pitch in candidates: + off = _best_ruled_grid_offset_px(y_mid, pitch, steps=off_steps) + c = _grid_alignment_mse(y_mid, pitch, off) + if c < best_c - 1e-6: + best_c, best_p, best_o = c, pitch, off + elif abs(c - best_c) <= 1e-6 and pitch > best_p: + # Tie: prefer slightly larger pitch → fewer phantom row splits. + best_p, best_o = pitch, off + ref_lo = best_p * 0.92 + ref_hi = best_p * 1.08 + for _ in range(3): + floor_p = max(22.0, 1.05 * med_h) + cap_p = min(520.0, 4.5 * med_h) + for i in range(25): + pitch = ref_lo + (ref_hi - ref_lo) * (i / 24.0) if ref_hi > ref_lo else best_p + if pitch < floor_p or pitch > cap_p: + continue + off = _best_ruled_grid_offset_px(y_mid, pitch, steps=off_steps) + c = _grid_alignment_mse(y_mid, pitch, off) + if c < best_c - 1e-6: + best_c, best_p, best_o = c, pitch, off + elif abs(c - best_c) <= 1e-6 and pitch > best_p: + best_p, best_o = pitch, off + ref_lo = best_p * 0.985 + ref_hi = best_p * 1.015 + floor_p = max(22.0, 1.08 * med_h) + if best_p < floor_p: + best_p = floor_p + best_o = _best_ruled_grid_offset_px(y_mid, best_p, steps=off_steps) + return best_p, best_o + + +def _ruled_pitch_offset_with_row_height_scale( + y_mid: list[float], + med_h: float, + *, + off_steps: int = 96, +) -> tuple[float, float]: + """Pitch/offset used for clustering, G-code order, and SVG row bands (scaled row height).""" + pitch, off = _best_ruled_pitch_and_offset_px(y_mid, med_h, off_steps=off_steps) + s = RULED_LINE_ROW_HEIGHT_SCALE + if s <= 1.0 or not y_mid: + return pitch, off + pitch = float(pitch * s) + lo = max(22.0, 1.08 * med_h) + hi = min(520.0, 4.5 * med_h) + pitch = min(max(pitch, lo), hi) + off = _best_ruled_grid_offset_px(y_mid, pitch, steps=off_steps) + return pitch, off + + +def _estimate_ruled_line_pitch_px(sorted_y_centers: list[float], med_h: float) -> float: + """Ruled line spacing from sorted bbox **vertical centers**. + + On one ruled line, many centers lie close on Y → in sorted order they form a **dense + run** (small gaps). The next run is the line below; **large** gaps between consecutive + sorted centers estimate between-line steps; their median is **pitch**. + """ + if len(sorted_y_centers) < 2: + return float(max(22.0, med_h * 1.22)) + gaps = [ + sorted_y_centers[i + 1] - sorted_y_centers[i] + for i in range(len(sorted_y_centers) - 1) + ] + # Ignore tiny steps (same ruled line); keep strokes that sit on different rows. + sep = 0.40 * med_h + line_gaps = [g for g in gaps if g > sep] + if len(line_gaps) >= 2: + pitch = sorted(line_gaps)[len(line_gaps) // 2] + elif len(line_gaps) == 1: + pitch = line_gaps[0] + else: + pitch = max(22.0, med_h * 1.22) + pitch = max(float(pitch), 0.78 * med_h) + # Ruled spacing is not smaller than ~one text line of body height (tetradь). + pitch = max(float(pitch), 1.10 * med_h) + pitch = min(float(pitch), 4.2 * med_h) + return float(pitch) + + +def _best_ruled_grid_offset_px(y_mid: list[float], pitch: float, *, steps: int = 96) -> float: + """``off`` in ``[0, pitch)`` minimizing ``sum_i (y_i - (off + round((y_i-off)/pitch)*pitch))^2``.""" + if not y_mid or pitch <= 1e-6: + return 0.0 + inv = 1.0 / pitch + best_o = 0.0 + best_c = float("inf") + for s in range(steps): + o = (s / steps) * pitch + c = 0.0 + for y in y_mid: + k = round((y - o) * inv) + d = y - (o + k * pitch) + c += d * d + if c < best_c: + best_c = c + best_o = o + return best_o + + +def _indices_by_ruled_row_id(y_mid: list[float], pitch: float, off: float) -> list[list[int]]: + inv = 1.0 / pitch + by_row: defaultdict[int, list[int]] = defaultdict(list) + for i, y in enumerate(y_mid): + rid = int(round((y - off) * inv)) + by_row[rid].append(i) + order = sorted(by_row.keys()) + return [by_row[k] for k in order] + + +def _merge_row_clusters_if_centroids_close( + clusters: list[list[int]], + y_mid: list[float], + pitch: float, + med_h: float, +) -> list[list[int]]: + """Join row buckets whose bbox centers sit in the same ruled band (grid seam fix).""" + if len(clusters) < 2: + return clusters + # One ruled band can span ~1× body height between bbox centers (tall / multi-piece line). + thresh = max(12.0, 0.40 * pitch, 0.64 * med_h, 0.90 * med_h, 1.05 * med_h) + + def centroid(idxs: list[int]) -> float: + return sum(y_mid[i] for i in idxs) / len(idxs) + + ordered = sorted(clusters, key=centroid) + out: list[list[int]] = [] + cur = list(ordered[0]) + cur_c = centroid(cur) + for nxt in ordered[1:]: + nc = centroid(nxt) + if nc - cur_c <= thresh: + cur.extend(nxt) + cur_c = centroid(cur) + else: + out.append(cur) + cur = list(nxt) + cur_c = nc + out.append(cur) return out +def _prepare_row_clusters_overlap( + n: int, + y_spans: list[tuple[float, float, float]], + cy: list[float], + med_h: float, +) -> list[list[int]]: + """Legacy row grouping: DSU on core overlap + mid-Y bands + gap refinement.""" + overlap_ratio = 0.38 + center_band_overlap = 0.82 + center_band_gap = 0.46 + core_shrink = 0.11 + dsu = _DSU(n) + for i in range(n): + ymin_i, ymax_i, hi = y_spans[i] + ci_lo, ci_hi = _shrink_y_interval(ymin_i, ymax_i, core_shrink) + for j in range(i + 1, n): + ymin_j, ymax_j, hj = y_spans[j] + cj_lo, cj_hi = _shrink_y_interval(ymin_j, ymax_j, core_shrink) + dy = abs(cy[i] - cy[j]) + ov = _vertical_overlap_px(ci_lo, ci_hi, cj_lo, cj_hi) + if ov >= overlap_ratio * min(hi, hj): + if dy <= center_band_overlap * med_h: + dsu.union(i, j) + else: + gap = max(ymin_i, ymin_j) - min(ymax_i, ymax_j) + if ( + gap > 0 + and gap <= 0.38 * med_h + and dy <= center_band_gap * med_h + ): + dsu.union(i, j) + + groups: defaultdict[int, list[int]] = defaultdict(list) + for i in range(n): + groups[dsu.find(i)].append(i) + + clusters = list(groups.values()) + for _ in range(4): + new_c = _refine_row_clusters_by_cy(clusters, cy, med_h) + if len(new_c) == len(clusters): + break + clusters = new_c + return clusters + + +def _prepare_row_clusters( + polys_px: list[list[tuple[float, float]]], + bboxes_px: list[tuple[float, float, float, float]], +) -> tuple[ + list[list[tuple[float, float]]], + list[tuple[float, float, float]], + float, + list[tuple[float, float, float, float]], + list[list[int]], +]: + """Build oriented polylines, ``y_spans``, ``med_h``, and sorted row clusters. + + Rows follow a **uniform ruled-line grid** in page Y when possible; used by + ``order_strokes`` and debug row overlays. + """ + n = len(polys_px) + y_spans: list[tuple[float, float, float]] = [] + oriented: list[list[tuple[float, float]]] = [] + for p, bb in zip(polys_px, bboxes_px): + o = _orient_poly_left_to_right(list(p)) + oriented.append(o) + ymin, ymax = bb[1], bb[3] + h = max(1e-3, ymax - ymin) + y_spans.append((ymin, ymax, h)) + + heights = [t[2] for t in y_spans] + med_h = sorted(heights)[len(heights) // 2] + cy_bbox = [0.5 * (t[0] + t[1]) for t in y_spans] + y_mid = cy_bbox + + if n < 2: + clusters = [list(range(n))] + else: + pitch, off = _ruled_pitch_offset_with_row_height_scale(y_mid, med_h) + clusters = _indices_by_ruled_row_id(y_mid, pitch, off) + clusters = _merge_row_clusters_if_centroids_close(clusters, y_mid, pitch, med_h) + span_y = max(y_mid) - min(y_mid) + too_merged = len(clusters) == 1 and span_y > 1.88 * pitch and n >= 8 + too_split = len(clusters) >= max(28, int(0.52 * n)) and n >= 14 + bad_pitch = pitch < 0.72 * med_h or pitch > 4.0 * med_h + if bad_pitch or too_merged or too_split: + clusters = _prepare_row_clusters_overlap(n, y_spans, cy_bbox, med_h) + + clusters.sort(key=lambda idxs: min(y_spans[i][0] for i in idxs)) + return oriented, y_spans, med_h, bboxes_px, clusters + + +def row_cluster_indices_for_page(page: dict[str, Any]) -> list[list[int]]: + """Row groups used for stroke ordering and debug SVG (indices into page stroke list). + + Rows are top-to-bottom (smaller page Y first). + """ + polys, boxes = collect_stroke_polys_and_bboxes_px_for_page(page) + if not polys: + return [] + *_, clusters = _prepare_row_clusters(polys, boxes) + return clusters + + +def order_strokes( + polys_px: list[list[tuple[float, float]]], + *, + bboxes_px: list[tuple[float, float, float, float]] | None = None, +) -> list[list[tuple[float, float]]]: + """Handwriting-like order: **ruled rows** top-to-bottom, never interleaving rows. + + Inside each row: cluster **primary** strokes into left-to-right **X-bands** (word-sized + gaps), chain each band with a left-seed greedy NN (penalty on pen-up moves left), then + attach **debris** (dots, tiny marks) to the nearest band by center-X and chain them from + the last stroke of that band — closer to “finish the word, then dot the i”. + + After each full row, the first stroke of the next row prefers a pen-down near + **~1.5× intra-line slack** below the previous row’s bottom (carriage return). + """ + if not polys_px: + return [] + n = len(polys_px) + if bboxes_px is None: + bboxes_px = [] + for p in polys_px: + o = _orient_poly_left_to_right(list(p)) + bboxes_px.append(_bbox_px(o)) + elif len(bboxes_px) != n: + raise ValueError("bboxes_px must match polys_px length") + + oriented, _y_spans, med_h, bboxes_px, clusters = _prepare_row_clusters( + polys_px, bboxes_px + ) + intra_line_y_slack = max(20.0, 1.10 * med_h) + y_w = 1.35 + back_w = 4.5 + + order_idx: list[int] = [] + prev_row_bottom: float | None = None + + for row_i, idxs in enumerate(clusters): + if not idxs: + continue + primary = [i for i in idxs if not _is_debris_stroke(bboxes_px[i], med_h)] + debris = [i for i in idxs if _is_debris_stroke(bboxes_px[i], med_h)] + row_bottom = max(bboxes_px[i][3] for i in idxs) + + if not primary: + tail = _order_row_indices_minimize_travel( + idxs, oriented, bboxes_px, med_h + ) + order_idx.extend(tail) + prev_row_bottom = row_bottom + continue + + groups = _cluster_row_primaries_by_x_gap(primary, bboxes_px, med_h) + debris_map = _assign_debris_to_x_groups(debris, groups, bboxes_px) + prefer_y: float | None = None + if row_i > 0 and prev_row_bottom is not None: + prefer_y = prev_row_bottom + 1.5 * intra_line_y_slack + + for gi, g in enumerate(groups): + use_prefer = prefer_y if gi == 0 else None + g_ord = _greedy_chain_best_of_seeds( + g, + oriented, + bboxes_px, + med_h, + y_weight=y_w, + back_x_weight=back_w, + max_seed_trials=5, + prefer_start_y=use_prefer, + ) + order_idx.extend(g_ord) + dlist = debris_map.get(gi) or [] + if dlist: + order_idx.extend( + _chain_debris_from_last_index( + dlist, + oriented, + order_idx[-1], + y_weight=y_w, + back_x_weight=back_w, + ) + ) + + prev_row_bottom = row_bottom + + return [oriented[i] for i in order_idx] + + def collect_strokes_mm_for_page( page: dict[str, Any], width_mm: float, @@ -159,13 +954,13 @@ def collect_strokes_mm_for_page( offset_x_mm: float, offset_y_mm: float, *, - writing_order: bool = False, + writing_order: bool = True, ) -> list[list[tuple[float, float]]]: pw = max(1, int(page.get("width") or 1)) ph = max(1, int(page.get("height") or 1)) - polys_px = collect_strokes_px_for_page(page) + polys_px, bboxes_px = collect_stroke_polys_and_bboxes_px_for_page(page) if writing_order: - polys_px = order_strokes_writing_mode(polys_px) + polys_px = order_strokes(polys_px, bboxes_px=bboxes_px) out: list[list[tuple[float, float]]] = [] for poly in polys_px: out.append( @@ -220,7 +1015,7 @@ def build_page_gcode( include_preamble: bool, include_postamble: bool, *, - writing_order: bool = False, + writing_order: bool = True, ) -> str: pw = max(1, int(page.get("width") or 1)) ph = max(1, int(page.get("height") or 1)) @@ -235,7 +1030,7 @@ def build_page_gcode( chunks: list[str] = [] chunks.append(f"( page {page_index + 1} / {page_count} )") chunks.append(f"( page pixels {pw} x {ph}, target width {width_mm:.3f} mm )") - wo = " writing-order" if writing_order else "" + wo = " reordered" if writing_order else "" chunks.append(f"( strokes {len(strokes)}{wo} )") body_lines: list[str] = [] @@ -257,7 +1052,7 @@ def build_combined_gcode( offset_y_mm: float, page_pause_m0: bool, *, - writing_order: bool = False, + writing_order: bool = True, ) -> str: pages = data.get("pages") or [] if not pages: @@ -281,7 +1076,7 @@ def build_combined_gcode( ) parts.append(f"( page {i + 1} / {n} )") parts.append(f"( page pixels {pw} x {ph}, target width {width_mm:.3f} mm )") - wo = " writing-order" if writing_order else "" + wo = " reordered" if writing_order else "" parts.append(f"( strokes {len(strokes)}{wo} )") parts.append("") parts.extend(_emit_strokes(strokes, profile)) @@ -305,7 +1100,7 @@ def write_gcode_outputs( offset_y_mm: float, page_pause_m0: bool, one_file_per_page: bool, - writing_order: bool = False, + writing_order: bool = True, ) -> list[Path]: profile = load_profile(profile_path) out_path.parent.mkdir(parents=True, exist_ok=True) From 2416859300fe244622bf42f96b39fd4acf39fad1 Mon Sep 17 00:00:00 2001 From: DanielL Date: Tue, 28 Apr 2026 01:51:52 +0200 Subject: [PATCH 4/4] Metrics: pen-up travel, row-scale and order tuning sweeps - Add pen_up_travel_xy_mm and samsung-notes metrics (sweep, optimize-row-scale) - StrokeOrderTuning dataclass; thread row_height_scale and tuning through G-code path - Sequential optimize sweeps for x-gap, chain weights, intra-line slack, carriage Y - plot_strokes: optional --row-height-scale for row overlay - Default RULED_LINE_ROW_HEIGHT_SCALE=1.1 after sample optimize - README: metrics and tuning examples --- README.md | 11 ++ cli.py | 363 ++++++++++++++++++++++++++++++++++++++++++++++++ plot_strokes.py | 29 +++- sdocx_gcode.py | 141 ++++++++++++++++--- 4 files changed, 521 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b61341b..32e444d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 # Stroke order is on by default: ruled rows top-to-bottom, then X-bands (word-ish gaps) left-to-right with local greedy chaining; tiny marks attach to the nearest band. Raw JSON order: --no-writing-order samsung-notes gcode note.sdocx -o plot.gcode --width-mm 120 +# Pen-up XY travel (sum of jumps between strokes in mm). Compare row-height scales, then fix the winner in sdocx_gcode.RULED_LINE_ROW_HEIGHT_SCALE or pass --row-height-scale to gcode/plot/inbox: +samsung-notes metrics note.sdocx +samsung-notes metrics note.sdocx --sweep-row-scale 1.0 1.15 1.5 1.725 2.0 +samsung-notes metrics note.sdocx --optimize-row-scale +samsung-notes metrics note.sdocx --optimize-row-scale 0.8:2.5:0.1 + +# Same file, sweep other ordering knobs (each flag alone uses a built-in MIN:MAX:STEP). Combine with --row-height-scale 1.1 to fix the row grid while sweeping: +samsung-notes metrics note.sdocx --row-height-scale 1.1 --optimize-x-gap-factor +samsung-notes metrics note.sdocx --row-height-scale 1.1 --optimize-chain-y-weight --optimize-chain-back-x-weight +# Available: --optimize-x-gap-factor, --optimize-chain-y-weight, --optimize-chain-back-x-weight, --optimize-intra-line-y-factor, --optimize-carriage-y-lines + # From saved JSON samsung-notes gcode --from-json extracted.json -o plot.gcode --width-mm 120 diff --git a/cli.py b/cli.py index a628a76..948670f 100644 --- a/cli.py +++ b/cli.py @@ -7,16 +7,123 @@ import json import sys import traceback +from dataclasses import replace from pathlib import Path from sdocx_extractor import extract_to_dict from sdocx_gcode import ( + RULED_LINE_ROW_HEIGHT_SCALE, + StrokeOrderTuning, + collect_stroke_polys_and_bboxes_px_for_page, + collect_strokes_mm_for_page, default_profile_path, load_extracted_json, + pen_up_travel_xy_mm, + row_cluster_indices_for_page, + uniform_row_pitch_px_for_page, write_gcode_outputs, ) from plot_strokes import generate_svg, get_output_path +# Default: sequential grid from MIN to MAX inclusive, step STEP (pen-up vs row_height_scale). +_DEFAULT_OPTIMIZE_RANGE = "0.8:2.5:0.1" + +_DEFAULT_SWEEP_X_GAP = "0.35:0.65:0.05" +_DEFAULT_SWEEP_CHAIN_Y = "1.0:1.8:0.1" +_DEFAULT_SWEEP_CHAIN_BACK_X = "3.0:6.5:0.5" +_DEFAULT_SWEEP_INTRA_LINE = "0.95:1.30:0.05" +_DEFAULT_SWEEP_CARRIAGE = "1.0:2.2:0.1" + + +def _parse_optimize_range(spec: str) -> tuple[float, float, float]: + parts = spec.strip().split(":") + if len(parts) != 3: + raise ValueError("expected MIN:MAX:STEP (e.g. 0.8:2.5:0.1)") + lo, hi, step = float(parts[0]), float(parts[1]), float(parts[2]) + if step <= 0 or lo > hi: + raise ValueError("need STEP > 0 and MIN <= MAX") + return lo, hi, step + + +def _linear_scales(lo: float, hi: float, step: float) -> list[float]: + scales: list[float] = [] + i = 0 + eps = max(1e-9, abs(step) * 1e-6) + while True: + v = lo + i * step + if v > hi + eps: + break + scales.append(round(v, 6)) + i += 1 + return scales + + +def _total_pen_up_mm( + pages: list[dict], + *, + width_mm: float, + flip_y: bool, + ox: float, + oy: float, + writing_order: bool, + row_height_scale: float | None, + stroke_order_tuning: StrokeOrderTuning | None = None, +) -> float: + total = 0.0 + for page in pages: + strokes = collect_strokes_mm_for_page( + page, + width_mm, + flip_y, + ox, + oy, + writing_order=writing_order, + row_height_scale=row_height_scale, + stroke_order_tuning=stroke_order_tuning, + ) + travel, _ = pen_up_travel_xy_mm(strokes) + total += travel + return total + + +def _run_order_param_sweep( + pages: list[dict], + args: argparse.Namespace, + *, + field: str, + title: str, + lo: float, + hi: float, + step: float, +) -> None: + candidates = _linear_scales(lo, hi, step) + if not candidates: + return + base = StrokeOrderTuning() + print( + f"\n# sweep {title} (pages={len(pages)} sequential {lo:g}..{hi:g} " + f"step {step:g} n={len(candidates)}; row_height_scale=" + f"{args.row_height_scale!r} other tuning=defaults)" + ) + print(f"# {field:24} total_pen_up_mm") + results: list[tuple[float, float]] = [] + for v in candidates: + t = replace(base, **{field: v}) + tot = _total_pen_up_mm( + pages, + width_mm=args.width_mm, + flip_y=args.flip_y, + ox=args.offset_x, + oy=args.offset_y, + writing_order=args.writing_order, + row_height_scale=args.row_height_scale, + stroke_order_tuning=t, + ) + results.append((v, tot)) + print(f" {v:22.6g} {tot:10.2f}") + best_v, best_tot = min(results, key=lambda x: (x[1], x[0])) + print(f"\nbest {field} = {best_v:g} total_pen_up_mm = {best_tot:.2f}") + def cmd_extract(args: argparse.Namespace) -> int: if not Path(args.sdocx).is_file(): @@ -74,6 +181,7 @@ def cmd_gcode(args: argparse.Namespace) -> int: page_pause_m0=not args.no_page_pause, one_file_per_page=args.one_file_per_page, writing_order=args.writing_order, + row_height_scale=args.row_height_scale, ) except Exception as e: print(f"Error: {e}", file=sys.stderr) @@ -116,6 +224,7 @@ def cmd_inbox(args: argparse.Namespace) -> int: page_pause_m0=not args.no_page_pause, one_file_per_page=args.one_file_per_page, writing_order=args.writing_order, + row_height_scale=args.row_height_scale, ) log_lines.append("OK gcode -> " + ", ".join(str(p) for p in paths)) if args.also_svg: @@ -125,6 +234,7 @@ def cmd_inbox(args: argparse.Namespace) -> int: svg_path, show_bbox=not args.no_bbox, show_rows=args.also_svg_rows, + row_height_scale=args.row_height_scale, ): log_lines.append(f"OK svg -> {svg_path}") else: @@ -183,6 +293,166 @@ def _add_gcode_flags(p: argparse.ArgumentParser) -> None: default=True, help="Reorder strokes for the plotter (default: on). Use --no-writing-order for JSON order.", ) + p.add_argument( + "--row-height-scale", + type=float, + default=None, + metavar="S", + help=( + "Ruled-line row pitch multiplier for clustering / G-code / SVG rows " + f"(default: {RULED_LINE_ROW_HEIGHT_SCALE} from sdocx_gcode)" + ), + ) + + +def cmd_metrics(args: argparse.Namespace) -> int: + try: + data = _load_gcode_input(args) + except SystemExit as e: + print(e, file=sys.stderr) + return 1 + pages = data.get("pages") or [] + if not pages: + print("No pages in document", file=sys.stderr) + return 1 + + ran_optimize = False + if getattr(args, "optimize_row_scale", None) is not None: + ran_optimize = True + try: + lo, hi, step = _parse_optimize_range(str(args.optimize_row_scale)) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 2 + candidates = _linear_scales(lo, hi, step) + if not candidates: + print("Error: empty scale range", file=sys.stderr) + return 2 + if not args.writing_order: + print( + "Warning: --no-writing-order ignores row clustering; " + "all scales give the same travel.", + file=sys.stderr, + ) + print( + f"\n# optimize total pen_up_mm (pages={len(pages)} " + f"sequential {lo:g}..{hi:g} step {step:g} n={len(candidates)})" + ) + print("# scale total_pen_up_mm (increasing scale — dependency curve)") + results: list[tuple[float, float]] = [] + for sc in candidates: + tot = _total_pen_up_mm( + pages, + width_mm=args.width_mm, + flip_y=args.flip_y, + ox=args.offset_x, + oy=args.offset_y, + writing_order=args.writing_order, + row_height_scale=sc, + stroke_order_tuning=None, + ) + results.append((sc, tot)) + print(f" {sc:6.4g} {tot:10.2f}") + best_s, best_total = min(results, key=lambda t: (t[1], t[0])) + print(f"\nbest row_height_scale = {best_s:g}") + print(f"total_pen_up_mm = {best_total:.2f}") + print( + f"\n# gcode: samsung-notes gcode NOTE.sdocx -o out.gcode " + f"--row-height-scale {best_s:g}" + ) + + _ORDER_SWEEPS: list[tuple[str, str, str]] = [ + ("optimize_x_gap_factor", "x_gap_med_h_factor", "x_gap_med_h_factor"), + ("optimize_chain_y_weight", "chain_y_weight", "chain_y_weight"), + ("optimize_chain_back_x_weight", "chain_back_x_weight", "chain_back_x_weight"), + ( + "optimize_intra_line_y_factor", + "intra_line_y_slack_factor", + "intra_line_y_slack_factor", + ), + ( + "optimize_carriage_y_lines", + "carriage_y_extra_lines", + "carriage_y_extra_lines", + ), + ] + for arg_attr, field, title in _ORDER_SWEEPS: + spec = getattr(args, arg_attr, None) + if spec is None: + continue + if not args.writing_order: + print( + f"Warning: --no-writing-order skips stroke reordering; " + f"{title} sweep is meaningless.", + file=sys.stderr, + ) + continue + ran_optimize = True + try: + lo, hi, step = _parse_optimize_range(str(spec)) + except ValueError as e: + print(f"Error ({arg_attr}): {e}", file=sys.stderr) + return 2 + _run_order_param_sweep( + pages, + args, + field=field, + title=title, + lo=lo, + hi=hi, + step=step, + ) + + if ran_optimize: + return 0 + + if getattr(args, "sweep_row_scale", None): + scales: list[float | None] = [float(s) for s in args.sweep_row_scale] + elif args.row_height_scale is not None: + scales = [args.row_height_scale] + else: + scales = [None] + + grand_travel = 0.0 + single_scale = len(scales) == 1 + + for pi, page in enumerate(pages): + polys, _ = collect_stroke_polys_and_bboxes_px_for_page(page) + n_strokes = len(polys) + mode = "reordered" if args.writing_order else "json-order" + print(f"\n# page {pi + 1}/{len(pages)} strokes={n_strokes} {mode}") + print( + f"{'scale':>10} {'rows':>5} {'pitch_px':>10} {'pen_up_mm':>12} {'jumps':>6}" + ) + for sc in scales: + eff = float(RULED_LINE_ROW_HEIGHT_SCALE if sc is None else sc) + strokes_s = collect_strokes_mm_for_page( + page, + args.width_mm, + args.flip_y, + args.offset_x, + args.offset_y, + writing_order=args.writing_order, + row_height_scale=sc, + stroke_order_tuning=None, + ) + travel, jumps = pen_up_travel_xy_mm(strokes_s) + if args.writing_order: + rows = len(row_cluster_indices_for_page(page, row_height_scale=sc)) + pitch = uniform_row_pitch_px_for_page(page, row_height_scale=sc) + else: + rows = 0 + pitch = 0.0 + row_s = f"{rows:5d}" if args.writing_order else " —" + pitch_s = f"{pitch:10.2f}" if args.writing_order else " —" + lbl = "default" if sc is None else f"{eff:.4g}" + print(f"{lbl:>10} {row_s} {pitch_s} {travel:12.2f} {jumps:6d}") + if single_scale: + grand_travel += travel + + if len(pages) > 1 and single_scale: + print(f"\n# total pen_up_mm (all pages): {grand_travel:.2f}") + return 0 def main() -> None: @@ -218,6 +488,99 @@ def main() -> None: _add_gcode_flags(p_gc) p_gc.set_defaults(func=cmd_gcode) + p_me = sub.add_parser( + "metrics", + help="Pen-up XY travel: row-scale and/or StrokeOrderTuning sweeps, or per-page tables", + ) + p_me.add_argument( + "sdocx", + nargs="?", + default=None, + help="Path to .sdocx (omit if using --from-json)", + ) + p_me.add_argument( + "--from-json", + metavar="FILE", + help="Use extracted JSON instead of parsing .sdocx", + ) + me_x = p_me.add_mutually_exclusive_group() + me_x.add_argument( + "--sweep-row-scale", + nargs="+", + type=float, + metavar="S", + help="Print a table for each listed scale (overrides a single --row-height-scale)", + ) + me_x.add_argument( + "--optimize-row-scale", + nargs="?", + const=_DEFAULT_OPTIMIZE_RANGE, + default=None, + metavar="MIN:MAX:STEP", + help=( + "Walk row-height-scale from MIN to MAX by STEP; print total pen-up mm per " + "value (increasing scale), then the minimum. Default if flag alone: " + f"{_DEFAULT_OPTIMIZE_RANGE}" + ), + ) + _add_gcode_flags(p_me) + p_me.add_argument( + "--optimize-x-gap-factor", + nargs="?", + const=_DEFAULT_SWEEP_X_GAP, + default=None, + metavar="MIN:MAX:STEP", + help=( + "Sweep x_gap_med_h_factor (word bands); default range if flag alone: " + f"{_DEFAULT_SWEEP_X_GAP}" + ), + ) + p_me.add_argument( + "--optimize-chain-y-weight", + nargs="?", + const=_DEFAULT_SWEEP_CHAIN_Y, + default=None, + metavar="MIN:MAX:STEP", + help=( + "Sweep chain_y_weight; default range if flag alone: " + f"{_DEFAULT_SWEEP_CHAIN_Y}" + ), + ) + p_me.add_argument( + "--optimize-chain-back-x-weight", + nargs="?", + const=_DEFAULT_SWEEP_CHAIN_BACK_X, + default=None, + metavar="MIN:MAX:STEP", + help=( + "Sweep chain_back_x_weight; default range if flag alone: " + f"{_DEFAULT_SWEEP_CHAIN_BACK_X}" + ), + ) + p_me.add_argument( + "--optimize-intra-line-y-factor", + nargs="?", + const=_DEFAULT_SWEEP_INTRA_LINE, + default=None, + metavar="MIN:MAX:STEP", + help=( + "Sweep intra_line_y_slack_factor; default range if flag alone: " + f"{_DEFAULT_SWEEP_INTRA_LINE}" + ), + ) + p_me.add_argument( + "--optimize-carriage-y-lines", + nargs="?", + const=_DEFAULT_SWEEP_CARRIAGE, + default=None, + metavar="MIN:MAX:STEP", + help=( + "Sweep carriage_y_extra_lines; default range if flag alone: " + f"{_DEFAULT_SWEEP_CARRIAGE}" + ), + ) + p_me.set_defaults(func=cmd_metrics) + p_in = sub.add_parser( "inbox", help="Convert all inbox/*.sdocx to outbox/*.gcode", diff --git a/plot_strokes.py b/plot_strokes.py index 98871cb..bbed388 100644 --- a/plot_strokes.py +++ b/plot_strokes.py @@ -52,6 +52,8 @@ def _append_row_debug_overlay( page: dict, width: float, height: float, + *, + row_height_scale: float | None = None, ) -> None: """Draw bands + dashed lines for ruled-line row clusters (see ``row_cluster_indices_for_page``).""" try: @@ -64,11 +66,15 @@ def _append_row_debug_overlay( print(f"Warning: row overlay needs sdocx_gcode: {e}", file=sys.stderr) return - clusters = row_cluster_indices_for_page(page) + clusters = row_cluster_indices_for_page( + page, row_height_scale=row_height_scale + ) _, boxes = collect_stroke_polys_and_bboxes_px_for_page(page) if not clusters: return - pitch = uniform_row_pitch_px_for_page(page) + pitch = uniform_row_pitch_px_for_page( + page, row_height_scale=row_height_scale + ) colors = [ "#ff4d00", "#00a86b", @@ -110,6 +116,7 @@ def generate_svg( stroke_width: float = 2.0, stroke_color: Optional[str] = None, show_rows: bool = False, + row_height_scale: float | None = None, ) -> bool: """Generate SVG from extracted stroke data. Returns True on success.""" if not data or not data.get("pages"): @@ -131,7 +138,13 @@ def generate_svg( ] if show_rows: - _append_row_debug_overlay(svg_lines, page, float(width), float(height)) + _append_row_debug_overlay( + svg_lines, + page, + float(width), + float(height), + row_height_scale=row_height_scale, + ) # Use single color if specified, otherwise cycle through palette colors = ( @@ -213,6 +226,7 @@ def process_file( show_bbox: bool = True, stroke_width: float = 2.0, show_rows: bool = False, + row_height_scale: float | None = None, ) -> bool: """Process a single SDOCX file and generate SVG.""" print(f"\nProcessing: {sdocx_path}") @@ -241,6 +255,7 @@ def process_file( show_bbox=show_bbox, stroke_width=stroke_width, show_rows=show_rows, + row_height_scale=row_height_scale, ): print(f" SVG: {svg_path}") return True @@ -291,6 +306,13 @@ def main(): action="store_true", help="Overlay ruled-line row bands (same grid as G-code stroke reordering)", ) + parser.add_argument( + "--row-height-scale", + type=float, + default=None, + metavar="S", + help="Ruled row pitch multiplier for --show-rows (default: sdocx_gcode.RULED_LINE_ROW_HEIGHT_SCALE)", + ) args = parser.parse_args() @@ -341,6 +363,7 @@ def main(): show_bbox=not args.no_bbox, stroke_width=args.stroke_width, show_rows=args.show_rows, + row_height_scale=args.row_height_scale, ): success_count += 1 diff --git a/sdocx_gcode.py b/sdocx_gcode.py index 13a85f8..caaef62 100644 --- a/sdocx_gcode.py +++ b/sdocx_gcode.py @@ -6,13 +6,30 @@ import json import tomllib from collections import defaultdict +from dataclasses import dataclass from pathlib import Path from typing import Any from plot_strokes import iter_objects # After fitting the ruled grid, scale pitch (>1 = taller rows, wider SVG bands, fewer Y splits). -RULED_LINE_ROW_HEIGHT_SCALE = 1.15 +RULED_LINE_ROW_HEIGHT_SCALE = 1.1 + + +@dataclass(frozen=True) +class StrokeOrderTuning: + """Tunable ordering heuristics (defaults match historical hard-coded behavior).""" + + # Horizontal band split: gap_thresh = max(3 px, x_gap_med_h_factor * med_h). + x_gap_med_h_factor: float = 0.48 + # Greedy chain: penalize vertical / backward-X pen-up moves. + chain_y_weight: float = 1.35 + chain_back_x_weight: float = 4.5 + # intra_line_y_slack = max(intra_line_y_slack_min_px, intra_line_y_slack_factor * med_h). + intra_line_y_slack_factor: float = 1.10 + intra_line_y_slack_min_px: float = 20.0 + # New row: target start Y ≈ prev_row_bottom + carriage_y_extra_lines * slack. + carriage_y_extra_lines: float = 1.5 def default_profile_path() -> Path: @@ -69,6 +86,26 @@ def _px_to_mm( return x_mm, y_mm +def pen_up_travel_xy_mm( + strokes: list[list[tuple[float, float]]], +) -> tuple[float, int]: + """Sum of Euclidean XY jumps between stroke end and next stroke start (pen-up travel). + + Matches the travel implied by ``_emit_strokes`` between consecutive polylines. + Returns ``(total_mm, n_jumps)``; ``n_jumps`` is ``max(0, len(strokes) - 1)``. + """ + if len(strokes) < 2: + return 0.0, 0 + total = 0.0 + n = len(strokes) + for i in range(n - 1): + x0, y0 = strokes[i][-1] + x1, y1 = strokes[i + 1][0] + dx, dy = x1 - x0, y1 - y0 + total += (dx * dx + dy * dy) ** 0.5 + return total, n - 1 + + def _bbox_px(poly: list[tuple[float, float]]) -> tuple[float, float, float, float]: xs = [p[0] for p in poly] ys = [p[1] for p in poly] @@ -311,6 +348,9 @@ def _order_row_indices_minimize_travel( oriented: list[list[tuple[float, float]]], bboxes_px: list[tuple[float, float, float, float]], med_h: float, + *, + chain_y_weight: float = 1.35, + chain_back_x_weight: float = 4.5, ) -> list[int]: """Primary strokes: greedy travel chain with back-X penalty; try several left seeds. @@ -323,9 +363,8 @@ def _order_row_indices_minimize_travel( if not primary: primary = list(idxs) debris = [] - y_w = 1.35 - # Stronger penalty for pen-up → pen-down that moves left (px space). - back_w = 4.5 + y_w = chain_y_weight + back_w = chain_back_x_weight out = _greedy_chain_best_of_seeds( primary, oriented, @@ -354,11 +393,13 @@ def _cluster_row_primaries_by_x_gap( primary_idxs: list[int], bboxes_px: list[tuple[float, float, float, float]], med_h: float, + *, + gap_med_h_factor: float = 0.48, ) -> list[list[int]]: """Split primary strokes into left-to-right bands (word-ish) by bbox gaps on X.""" if not primary_idxs: return [] - gap_thresh = max(3.0, 0.48 * med_h) + gap_thresh = max(3.0, gap_med_h_factor * med_h) sorted_i = sorted(primary_idxs, key=lambda i: (bboxes_px[i][0], bboxes_px[i][2])) groups: list[list[int]] = [] cur = [sorted_i[0]] @@ -498,7 +539,11 @@ def collect_strokes_px_for_page(page: dict[str, Any]) -> list[list[tuple[float, return polys -def uniform_row_pitch_px_for_page(page: dict[str, Any]) -> float: +def uniform_row_pitch_px_for_page( + page: dict[str, Any], + *, + row_height_scale: float | None = None, +) -> float: """Ruled line spacing (px) for debug bands: same grid as stroke reordering.""" _, boxes = collect_stroke_polys_and_bboxes_px_for_page(page) if not boxes: @@ -506,12 +551,16 @@ def uniform_row_pitch_px_for_page(page: dict[str, Any]) -> float: heights = [max(1e-3, bb[3] - bb[1]) for bb in boxes] med_h = sorted(heights)[len(heights) // 2] y_mid = [_stroke_y_center_bbox_px(bb) for bb in boxes] - pitch, _ = _ruled_pitch_offset_with_row_height_scale(y_mid, med_h) + pitch, _ = _ruled_pitch_offset_with_row_height_scale( + y_mid, med_h, row_height_scale=row_height_scale + ) return float(pitch) def ruled_line_grid_meta_for_page( page: dict[str, Any], + *, + row_height_scale: float | None = None, ) -> tuple[float, float, int, int] | None: """Pitch, offset, min_row_index, max_row_index (inclusive), including empty ruled rows. @@ -523,7 +572,9 @@ def ruled_line_grid_meta_for_page( heights = [max(1e-3, bb[3] - bb[1]) for bb in boxes] med_h = sorted(heights)[len(heights) // 2] y_mid = [_stroke_y_center_bbox_px(bb) for bb in boxes] - pitch, off = _ruled_pitch_offset_with_row_height_scale(y_mid, med_h) + pitch, off = _ruled_pitch_offset_with_row_height_scale( + y_mid, med_h, row_height_scale=row_height_scale + ) inv = 1.0 / max(pitch, 1e-6) rids = [int(round((y - off) * inv)) for y in y_mid] return float(pitch), float(off), min(rids), max(rids) @@ -651,10 +702,11 @@ def _ruled_pitch_offset_with_row_height_scale( med_h: float, *, off_steps: int = 96, + row_height_scale: float | None = None, ) -> tuple[float, float]: """Pitch/offset used for clustering, G-code order, and SVG row bands (scaled row height).""" pitch, off = _best_ruled_pitch_and_offset_px(y_mid, med_h, off_steps=off_steps) - s = RULED_LINE_ROW_HEIGHT_SCALE + s = float(RULED_LINE_ROW_HEIGHT_SCALE if row_height_scale is None else row_height_scale) if s <= 1.0 or not y_mid: return pitch, off pitch = float(pitch * s) @@ -804,6 +856,8 @@ def _prepare_row_clusters_overlap( def _prepare_row_clusters( polys_px: list[list[tuple[float, float]]], bboxes_px: list[tuple[float, float, float, float]], + *, + row_height_scale: float | None = None, ) -> tuple[ list[list[tuple[float, float]]], list[tuple[float, float, float]], @@ -834,7 +888,9 @@ def _prepare_row_clusters( if n < 2: clusters = [list(range(n))] else: - pitch, off = _ruled_pitch_offset_with_row_height_scale(y_mid, med_h) + pitch, off = _ruled_pitch_offset_with_row_height_scale( + y_mid, med_h, row_height_scale=row_height_scale + ) clusters = _indices_by_ruled_row_id(y_mid, pitch, off) clusters = _merge_row_clusters_if_centroids_close(clusters, y_mid, pitch, med_h) span_y = max(y_mid) - min(y_mid) @@ -848,7 +904,11 @@ def _prepare_row_clusters( return oriented, y_spans, med_h, bboxes_px, clusters -def row_cluster_indices_for_page(page: dict[str, Any]) -> list[list[int]]: +def row_cluster_indices_for_page( + page: dict[str, Any], + *, + row_height_scale: float | None = None, +) -> list[list[int]]: """Row groups used for stroke ordering and debug SVG (indices into page stroke list). Rows are top-to-bottom (smaller page Y first). @@ -856,7 +916,9 @@ def row_cluster_indices_for_page(page: dict[str, Any]) -> list[list[int]]: polys, boxes = collect_stroke_polys_and_bboxes_px_for_page(page) if not polys: return [] - *_, clusters = _prepare_row_clusters(polys, boxes) + *_, clusters = _prepare_row_clusters( + polys, boxes, row_height_scale=row_height_scale + ) return clusters @@ -864,6 +926,8 @@ def order_strokes( polys_px: list[list[tuple[float, float]]], *, bboxes_px: list[tuple[float, float, float, float]] | None = None, + row_height_scale: float | None = None, + stroke_order_tuning: StrokeOrderTuning | None = None, ) -> list[list[tuple[float, float]]]: """Handwriting-like order: **ruled rows** top-to-bottom, never interleaving rows. @@ -887,11 +951,15 @@ def order_strokes( raise ValueError("bboxes_px must match polys_px length") oriented, _y_spans, med_h, bboxes_px, clusters = _prepare_row_clusters( - polys_px, bboxes_px + polys_px, bboxes_px, row_height_scale=row_height_scale ) - intra_line_y_slack = max(20.0, 1.10 * med_h) - y_w = 1.35 - back_w = 4.5 + tun = stroke_order_tuning or StrokeOrderTuning() + intra_line_y_slack = max( + tun.intra_line_y_slack_min_px, + tun.intra_line_y_slack_factor * med_h, + ) + y_w = tun.chain_y_weight + back_w = tun.chain_back_x_weight order_idx: list[int] = [] prev_row_bottom: float | None = None @@ -905,17 +973,29 @@ def order_strokes( if not primary: tail = _order_row_indices_minimize_travel( - idxs, oriented, bboxes_px, med_h + idxs, + oriented, + bboxes_px, + med_h, + chain_y_weight=y_w, + chain_back_x_weight=back_w, ) order_idx.extend(tail) prev_row_bottom = row_bottom continue - groups = _cluster_row_primaries_by_x_gap(primary, bboxes_px, med_h) + groups = _cluster_row_primaries_by_x_gap( + primary, + bboxes_px, + med_h, + gap_med_h_factor=tun.x_gap_med_h_factor, + ) debris_map = _assign_debris_to_x_groups(debris, groups, bboxes_px) prefer_y: float | None = None if row_i > 0 and prev_row_bottom is not None: - prefer_y = prev_row_bottom + 1.5 * intra_line_y_slack + prefer_y = ( + prev_row_bottom + tun.carriage_y_extra_lines * intra_line_y_slack + ) for gi, g in enumerate(groups): use_prefer = prefer_y if gi == 0 else None @@ -955,12 +1035,19 @@ def collect_strokes_mm_for_page( offset_y_mm: float, *, writing_order: bool = True, + row_height_scale: float | None = None, + stroke_order_tuning: StrokeOrderTuning | None = None, ) -> list[list[tuple[float, float]]]: pw = max(1, int(page.get("width") or 1)) ph = max(1, int(page.get("height") or 1)) polys_px, bboxes_px = collect_stroke_polys_and_bboxes_px_for_page(page) if writing_order: - polys_px = order_strokes(polys_px, bboxes_px=bboxes_px) + polys_px = order_strokes( + polys_px, + bboxes_px=bboxes_px, + row_height_scale=row_height_scale, + stroke_order_tuning=stroke_order_tuning, + ) out: list[list[tuple[float, float]]] = [] for poly in polys_px: out.append( @@ -1016,6 +1103,8 @@ def build_page_gcode( include_postamble: bool, *, writing_order: bool = True, + row_height_scale: float | None = None, + stroke_order_tuning: StrokeOrderTuning | None = None, ) -> str: pw = max(1, int(page.get("width") or 1)) ph = max(1, int(page.get("height") or 1)) @@ -1026,6 +1115,8 @@ def build_page_gcode( offset_x_mm, offset_y_mm, writing_order=writing_order, + row_height_scale=row_height_scale, + stroke_order_tuning=stroke_order_tuning, ) chunks: list[str] = [] chunks.append(f"( page {page_index + 1} / {page_count} )") @@ -1053,6 +1144,8 @@ def build_combined_gcode( page_pause_m0: bool, *, writing_order: bool = True, + row_height_scale: float | None = None, + stroke_order_tuning: StrokeOrderTuning | None = None, ) -> str: pages = data.get("pages") or [] if not pages: @@ -1073,6 +1166,8 @@ def build_combined_gcode( offset_x_mm, offset_y_mm, writing_order=writing_order, + row_height_scale=row_height_scale, + stroke_order_tuning=stroke_order_tuning, ) parts.append(f"( page {i + 1} / {n} )") parts.append(f"( page pixels {pw} x {ph}, target width {width_mm:.3f} mm )") @@ -1101,6 +1196,8 @@ def write_gcode_outputs( page_pause_m0: bool, one_file_per_page: bool, writing_order: bool = True, + row_height_scale: float | None = None, + stroke_order_tuning: StrokeOrderTuning | None = None, ) -> list[Path]: profile = load_profile(profile_path) out_path.parent.mkdir(parents=True, exist_ok=True) @@ -1126,6 +1223,8 @@ def write_gcode_outputs( include_preamble=True, include_postamble=True, writing_order=writing_order, + row_height_scale=row_height_scale, + stroke_order_tuning=stroke_order_tuning, ) chunk_path.write_text(text, encoding="utf-8") written.append(chunk_path) @@ -1139,6 +1238,8 @@ def write_gcode_outputs( offset_y_mm, page_pause_m0, writing_order=writing_order, + row_height_scale=row_height_scale, + stroke_order_tuning=stroke_order_tuning, ) out_path.write_text(text, encoding="utf-8") written.append(out_path)