From c43f6fe11e81ed7cc976eb8308f18afc8c5cfe96 Mon Sep 17 00:00:00 2001 From: SimpleCaci <92838396+SimpleCaci@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:56:14 -0800 Subject: [PATCH 1/2] style: satisfy current Ruff checks --- main.py | 654 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 327 insertions(+), 327 deletions(-) diff --git a/main.py b/main.py index 2cb02bf..27ccefd 100644 --- a/main.py +++ b/main.py @@ -1,327 +1,327 @@ -"""Convert photographed handwriting into searchable text and reviewable documents.""" - -from __future__ import annotations - -import argparse -import base64 -import html -import mimetypes -import shutil -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable - -import cv2 -import pytesseract -from reportlab.lib.pagesizes import letter -from reportlab.lib.styles import getSampleStyleSheet -from reportlab.lib.units import inch -from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer - - -@dataclass(frozen=True) -class ConversionResult: - source: Path - text: str - confidence: float | None = None - processed_image: Path | None = None - - -def preprocess_image(path: Path, max_width: int = 2200): - """Load, deskew-friendly enhance, and binarize a handwriting image.""" - image = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) - if image is None: - raise ValueError(f"Unsupported or unreadable image: {path}") - - if image.shape[1] > max_width: - scale = max_width / image.shape[1] - image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) - - image = cv2.fastNlMeansDenoising(image, None, 12, 7, 21) - image = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(image) - return cv2.adaptiveThreshold( - image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 15 - ) - - -def verify_tesseract(command: str | None = None) -> None: - if command: - pytesseract.pytesseract.tesseract_cmd = command - executable = pytesseract.pytesseract.tesseract_cmd - if not shutil.which(executable) and not Path(executable).exists(): - raise RuntimeError( - "Tesseract OCR was not found. Install Tesseract or pass --tesseract " - "with the executable path." - ) - try: - pytesseract.get_tesseract_version() - except pytesseract.TesseractNotFoundError as exc: - raise RuntimeError("Tesseract OCR is installed but could not be started.") from exc - - -def convert_images( - inputs: Iterable[Path], - *, - language: str = "eng", - page_segmentation: int = 6, - debug_dir: Path | None = None, -) -> list[ConversionResult]: - results: list[ConversionResult] = [] - config = f"--oem 3 --psm {page_segmentation}" - - for index, source in enumerate(inputs, start=1): - if not source.is_file(): - raise FileNotFoundError(f"Input image does not exist: {source}") - processed = preprocess_image(source) - ocr_data = pytesseract.image_to_data( - processed, - lang=language, - config=config, - output_type=pytesseract.Output.DICT, - ) - words = [word.strip() for word in ocr_data["text"] if word.strip()] - confidences = [ - float(value) - for value in ocr_data["conf"] - if float(value) >= 0 - ] - text = " ".join(words) - confidence = ( - sum(confidences) / len(confidences) if confidences else None - ) - - debug_path = None - if debug_dir: - debug_dir.mkdir(parents=True, exist_ok=True) - debug_path = debug_dir / f"{index:02d}-{source.stem}-processed.png" - cv2.imwrite(str(debug_path), processed) - - results.append( - ConversionResult( - source=source, - text=text, - confidence=confidence, - processed_image=debug_path, - ) - ) - return results - - -def write_text(results: list[ConversionResult], output: Path) -> None: - output.parent.mkdir(parents=True, exist_ok=True) - sections = [ - f"# {item.source.name}\n\n{item.text or '[No text detected]'}" - for item in results - ] - output.write_text("\n\n".join(sections) + "\n", encoding="utf-8") - - -def write_pdf(results: list[ConversionResult], output: Path, title: str) -> None: - output.parent.mkdir(parents=True, exist_ok=True) - styles = getSampleStyleSheet() - story = [Paragraph(title, styles["Title"]), Spacer(1, 0.25 * inch)] - for index, item in enumerate(results): - story.extend( - [ - Paragraph(item.source.name, styles["Heading2"]), - Paragraph( - html.escape(item.text or "[No text detected]").replace("\n", "
"), - styles["BodyText"], - ), - ] - ) - if index < len(results) - 1: - story.append(PageBreak()) - SimpleDocTemplate( - str(output), - pagesize=letter, - title=title, - author="SimpleCaci", - rightMargin=0.65 * inch, - leftMargin=0.65 * inch, - topMargin=0.65 * inch, - bottomMargin=0.65 * inch, - ).build(story) - - -def _image_data_uri(path: Path) -> str: - media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" - encoded = base64.b64encode(path.read_bytes()).decode("ascii") - return f"data:{media_type};base64,{encoded}" - - -def write_review_report( - results: list[ConversionResult], output: Path, title: str -) -> None: - """Write a self-contained visual OCR review report.""" - output.parent.mkdir(parents=True, exist_ok=True) - cards = [] - for index, item in enumerate(results, start=1): - confidence = ( - "Confidence unavailable" - if item.confidence is None - else f"Average OCR confidence {item.confidence:.0f}%" - ) - transcription = html.escape(item.text or "[No text detected]") - cards.append( - f""" -
-
- Page {index} -

{html.escape(item.source.name)}

- {confidence} -
-
-
- Original handwriting page {index} -
Original page
-
-
-

Recognized text

-
{transcription}
-
-
-
- """ - ) - - document = f""" - - - - - {html.escape(title)} · OCR review - - - -
-
-

{html.escape(title)}

-

Compare every original page with its recognized text before editing, - sharing, or printing the transcription.

-
- {"".join(cards)} -
- - -""" - output.write_text(document, encoding="utf-8") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Turn one or more handwriting photos into reviewable documents." - ) - parser.add_argument("inputs", nargs="+", type=Path, help="Image files in reading order") - parser.add_argument("-o", "--output", type=Path, default=Path("output/handwriting")) - parser.add_argument("--title", default="Handwriting transcription") - parser.add_argument("--language", default="eng", help="Installed Tesseract language code") - parser.add_argument("--psm", type=int, default=6, choices=range(0, 14)) - parser.add_argument("--tesseract", help="Path to the Tesseract executable") - parser.add_argument("--debug-images", action="store_true") - parser.add_argument( - "--no-review-report", - action="store_true", - help="Skip the self-contained HTML source/transcription review report", - ) - return parser - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - try: - verify_tesseract(args.tesseract) - results = convert_images( - args.inputs, - language=args.language, - page_segmentation=args.psm, - debug_dir=args.output.parent / "debug" if args.debug_images else None, - ) - write_text(results, args.output.with_suffix(".txt")) - write_pdf(results, args.output.with_suffix(".pdf"), args.title) - if not args.no_review_report: - write_review_report(results, args.output.with_suffix(".html"), args.title) - except (FileNotFoundError, RuntimeError, ValueError) as exc: - print(f"error: {exc}") - return 1 - - detected = sum(bool(item.text) for item in results) - print(f"Converted {len(results)} page(s); detected text on {detected}.") - print(f"Text: {args.output.with_suffix('.txt')}") - print(f"PDF: {args.output.with_suffix('.pdf')}") - if not args.no_review_report: - print(f"Review: {args.output.with_suffix('.html')}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +"""Convert photographed handwriting into searchable text and reviewable documents.""" + +from __future__ import annotations + +import argparse +import base64 +import html +import mimetypes +import shutil +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import pytesseract +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer + + +@dataclass(frozen=True) +class ConversionResult: + source: Path + text: str + confidence: float | None = None + processed_image: Path | None = None + + +def preprocess_image(path: Path, max_width: int = 2200): + """Load, deskew-friendly enhance, and binarize a handwriting image.""" + image = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) + if image is None: + raise ValueError(f"Unsupported or unreadable image: {path}") + + if image.shape[1] > max_width: + scale = max_width / image.shape[1] + image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + + image = cv2.fastNlMeansDenoising(image, None, 12, 7, 21) + image = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(image) + return cv2.adaptiveThreshold( + image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 15 + ) + + +def verify_tesseract(command: str | None = None) -> None: + if command: + pytesseract.pytesseract.tesseract_cmd = command + executable = pytesseract.pytesseract.tesseract_cmd + if not shutil.which(executable) and not Path(executable).exists(): + raise RuntimeError( + "Tesseract OCR was not found. Install Tesseract or pass --tesseract " + "with the executable path." + ) + try: + pytesseract.get_tesseract_version() + except pytesseract.TesseractNotFoundError as exc: + raise RuntimeError("Tesseract OCR is installed but could not be started.") from exc + + +def convert_images( + inputs: Iterable[Path], + *, + language: str = "eng", + page_segmentation: int = 6, + debug_dir: Path | None = None, +) -> list[ConversionResult]: + results: list[ConversionResult] = [] + config = f"--oem 3 --psm {page_segmentation}" + + for index, source in enumerate(inputs, start=1): + if not source.is_file(): + raise FileNotFoundError(f"Input image does not exist: {source}") + processed = preprocess_image(source) + ocr_data = pytesseract.image_to_data( + processed, + lang=language, + config=config, + output_type=pytesseract.Output.DICT, + ) + words = [word.strip() for word in ocr_data["text"] if word.strip()] + confidences = [ + float(value) + for value in ocr_data["conf"] + if float(value) >= 0 + ] + text = " ".join(words) + confidence = ( + sum(confidences) / len(confidences) if confidences else None + ) + + debug_path = None + if debug_dir: + debug_dir.mkdir(parents=True, exist_ok=True) + debug_path = debug_dir / f"{index:02d}-{source.stem}-processed.png" + cv2.imwrite(str(debug_path), processed) + + results.append( + ConversionResult( + source=source, + text=text, + confidence=confidence, + processed_image=debug_path, + ) + ) + return results + + +def write_text(results: list[ConversionResult], output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + sections = [ + f"# {item.source.name}\n\n{item.text or '[No text detected]'}" + for item in results + ] + output.write_text("\n\n".join(sections) + "\n", encoding="utf-8") + + +def write_pdf(results: list[ConversionResult], output: Path, title: str) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + styles = getSampleStyleSheet() + story = [Paragraph(title, styles["Title"]), Spacer(1, 0.25 * inch)] + for index, item in enumerate(results): + story.extend( + [ + Paragraph(item.source.name, styles["Heading2"]), + Paragraph( + html.escape(item.text or "[No text detected]").replace("\n", "
"), + styles["BodyText"], + ), + ] + ) + if index < len(results) - 1: + story.append(PageBreak()) + SimpleDocTemplate( + str(output), + pagesize=letter, + title=title, + author="SimpleCaci", + rightMargin=0.65 * inch, + leftMargin=0.65 * inch, + topMargin=0.65 * inch, + bottomMargin=0.65 * inch, + ).build(story) + + +def _image_data_uri(path: Path) -> str: + media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + return f"data:{media_type};base64,{encoded}" + + +def write_review_report( + results: list[ConversionResult], output: Path, title: str +) -> None: + """Write a self-contained visual OCR review report.""" + output.parent.mkdir(parents=True, exist_ok=True) + cards = [] + for index, item in enumerate(results, start=1): + confidence = ( + "Confidence unavailable" + if item.confidence is None + else f"Average OCR confidence {item.confidence:.0f}%" + ) + transcription = html.escape(item.text or "[No text detected]") + cards.append( + f""" +
+
+ Page {index} +

{html.escape(item.source.name)}

+ {confidence} +
+
+
+ Original handwriting page {index} +
Original page
+
+
+

Recognized text

+
{transcription}
+
+
+
+ """ + ) + + document = f""" + + + + + {html.escape(title)} · OCR review + + + +
+
+

{html.escape(title)}

+

Compare every original page with its recognized text before editing, + sharing, or printing the transcription.

+
+ {"".join(cards)} +
+ + +""" + output.write_text(document, encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Turn one or more handwriting photos into reviewable documents." + ) + parser.add_argument("inputs", nargs="+", type=Path, help="Image files in reading order") + parser.add_argument("-o", "--output", type=Path, default=Path("output/handwriting")) + parser.add_argument("--title", default="Handwriting transcription") + parser.add_argument("--language", default="eng", help="Installed Tesseract language code") + parser.add_argument("--psm", type=int, default=6, choices=range(14)) + parser.add_argument("--tesseract", help="Path to the Tesseract executable") + parser.add_argument("--debug-images", action="store_true") + parser.add_argument( + "--no-review-report", + action="store_true", + help="Skip the self-contained HTML source/transcription review report", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + verify_tesseract(args.tesseract) + results = convert_images( + args.inputs, + language=args.language, + page_segmentation=args.psm, + debug_dir=args.output.parent / "debug" if args.debug_images else None, + ) + write_text(results, args.output.with_suffix(".txt")) + write_pdf(results, args.output.with_suffix(".pdf"), args.title) + if not args.no_review_report: + write_review_report(results, args.output.with_suffix(".html"), args.title) + except (FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"error: {exc}") + return 1 + + detected = sum(bool(item.text) for item in results) + print(f"Converted {len(results)} page(s); detected text on {detected}.") + print(f"Text: {args.output.with_suffix('.txt')}") + print(f"PDF: {args.output.with_suffix('.pdf')}") + if not args.no_review_report: + print(f"Review: {args.output.with_suffix('.html')}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 40ab67cf4c97fd1dfa5870e8d5a9c9ae7e7d0696 Mon Sep 17 00:00:00 2001 From: SimpleCaci <92838396+SimpleCaci@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:56:46 -0800 Subject: [PATCH 2/2] docs: clarify OCR setup and contribution workflow --- CONTRIBUTING.md | 16 ++++++++++++++++ README.md | 4 ++++ 2 files changed, 20 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..924eb83 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing to HandwritingConverter + +Use Python 3.11+ and the Tesseract installation described in the README. Install `requirements-dev.txt` in a virtual environment, then run: + +```bash +ruff check main.py tests +python -m pytest -q +``` + +The deterministic tests replace the OCR call while exercising image preparation, reports, and file output. Changes to recognition behavior should also include a local Tesseract run and identify the language data and page-segmentation mode used. + +Keep sample notes synthetic or yours to share. Do not attach private class notes or someone else's handwriting without permission. For an OCR bug, describe lighting, image orientation, Tesseract version, command options, and expected versus observed text. Confidence scores are not accuracy measurements. + +For a pull request, explain the change and validation. Include a report screenshot for presentation changes, and preserve the distinction between raw OCR and manually reviewed text. Discuss a new OCR backend or desktop interface in an issue before adding large dependencies. + +Contributions follow the existing MIT license. There is no guaranteed support or response time. diff --git a/README.md b/README.md index 54c1edd..822785e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Turn photos of handwritten notes into searchable text and a printable PDF. +[![Checks](https://github.com/SimpleCaci/HandwritingConverter/actions/workflows/validate.yml/badge.svg)](https://github.com/SimpleCaci/HandwritingConverter/actions/workflows/validate.yml) + +[Install](#installation) · [Convert notes](#usage) · [Contribute](CONTRIBUTING.md) + ![HandwritingConverter before and after](docs/media/hero.png) I built this because writing by hand is often the fastest way for me to think, but paper notes are difficult to search, revise, and print with my portable printer. HandwritingConverter connects those two workflows without pretending that handwriting OCR is perfect.