Files
Esa Kataja b594968bb8 Add optional bilevel shrinking of the archived PDF
Scanned scores are black ink on white paper stored as 8-bit greyscale or
RGB, which costs several times what the same page costs as a bilevel
image. Across an 11-song corpus this is 27.6 MB to 7.7 MB; Engel's
bundle goes from 5997 KB to 2092 KB with byte-identical slices, since
only the archived copy changes.

Three approaches were measured and discarded first, which is worth
recording because two of them are the obvious ones. Converting RGB to
greyscale and re-encoding makes these files 20-86% LARGER: the source
JPEGs are already near 0.7 bits per pixel, so re-encoding adds
generation loss and spends more bits than the original did, and dropping
chroma recovers nothing because JPEG already subsamples it. Lossless
structural optimisation gains 0.1%, because images are 99% of every file
and there are no duplicates. Downsampling works but 300 DPI is print
resolution, and the PDF exists to be printed.

Two failure modes were found by looking at output rather than at byte
counts, and both are now refused:

- A scan at ~115 DPI came back with broken staff lines. Guarded on
  resolution as the image is *placed on the page*, so a tiled scan with
  126 small images still qualifies where a pixel count would reject it.
- Cover artwork was flattened to grey. Guarded on chroma: artwork
  measures 44% off-grey against 3% for sensor tint on a greyscale scan.
  The first threshold of 2% was a false positive that cost 685 KB on one
  song for nothing; 10% sits in the gap with room either side.

Exposed as a button rather than a checkbox. It reports what it skipped
and why, and shows a before/after crop, because the failure it can
produce is obvious at a glance and invisible in a size figure. Off by
default: this is lossy on the copy kept for printing.
2026-07-29 10:42:59 +03:00

97 lines
3.4 KiB
Python

"""Runnable check for optional PDF shrinking, including what it refuses to do."""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pymupdf
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer.pdfopt import MIN_DPI, optimise # noqa: E402
A4_PT = (595, 842)
def _pdf(path: Path, width: int, height: int, *, colour: bool = False, bilevel: bool = False):
"""One full-page image of ruled lines, at the given pixel size."""
art = np.full((height, width, 3), 255, np.uint8)
for i in range(6):
y = int(height * (0.2 + i * 0.03))
art[y : y + max(1, height // 900), int(width * 0.1) : int(width * 0.9)] = 0
if colour:
art[: height // 3, :, 0] = 40 # a strong blue cast over the top third
art[: height // 3, :, 1] = 90
grey = art[:, :, 0] if not colour else None
doc = pymupdf.open()
page = doc.new_page(width=A4_PT[0], height=A4_PT[1])
if bilevel:
pix = pymupdf.Pixmap(pymupdf.csGRAY, width, height, bytearray(grey.tobytes()), False)
page.insert_image(page.rect, pixmap=pix)
doc.save(path, garbage=4, deflate=True)
# Re-save through a 1-bit PNG so the stored image really is bilevel.
import cv2
ok, buf = cv2.imencode(".png", (grey > 127).astype(np.uint8) * 255)
doc2 = pymupdf.open()
p2 = doc2.new_page(width=A4_PT[0], height=A4_PT[1])
p2.insert_image(p2.rect, stream=buf.tobytes())
doc2.save(path, garbage=4, deflate=True)
return
stream = art if colour else np.dstack([grey] * 3)
import cv2
ok, buf = cv2.imencode(".jpg", stream, [cv2.IMWRITE_JPEG_QUALITY, 92])
page.insert_image(page.rect, stream=buf.tobytes())
doc.save(path, garbage=4, deflate=True)
def main() -> int:
tmp = Path(__file__).with_name("_tmp")
tmp.mkdir(exist_ok=True)
# A4 is 8.26in wide, so 2480px is ~300 DPI and 800px is ~97 DPI.
fine, coarse, colour = tmp / "fine.pdf", tmp / "coarse.pdf", tmp / "colour.pdf"
_pdf(fine, 2480, 3508)
_pdf(coarse, 800, 1130)
_pdf(colour, 2480, 3508, colour=True)
data, report = optimise(pymupdf.open(fine), fine.stat().st_size)
assert report.converted == 1, report.summary()
assert data, "a greyscale scan at 300 DPI should shrink"
assert report.ratio < 0.9, report.ratio
# The result must still be a readable PDF of the same page count.
assert len(pymupdf.open(stream=data, filetype="pdf")) == 1
# Too coarse: staff lines would break, so it is left alone.
_, report = optimise(pymupdf.open(coarse), coarse.stat().st_size)
assert report.converted == 0, report.summary()
assert any("DPI" in reason for reason in report.skipped), report.skipped
# Genuine colour: artwork is not thrown away.
_, report = optimise(pymupdf.open(colour), colour.stat().st_size)
assert report.converted == 0, report.summary()
assert any("colour" in reason for reason in report.skipped), report.skipped
# A no-op run reports honestly rather than returning something bigger.
empty = pymupdf.open()
empty.new_page()
data, report = optimise(empty, 1)
assert data == b"" and report.converted == 0
assert report.ratio == 1.0
assert MIN_DPI >= 150, "the floor exists to protect thin staff lines"
for f in (fine, coarse, colour):
f.unlink(missing_ok=True)
tmp.rmdir()
print("ok")
return 0
if __name__ == "__main__":
sys.exit(main())