- merge-annotations.py: rmscene GlyphRange-Blöcke auslesen und als SVG-Rects rendern - Koordinaten: rmscene screen-px × 72/226 → SVG-Punkte (identisch mit rmc-Skalierung) - Farben: fill='rgb(...)' + fill-opacity statt rgba() (Inkscape-Kompatibilität) - Highlights werden VOR Stift-Annotationen eingefügt (korrekte Z-Order) - Dockerfile: rmscene==0.6.1 (rmrl+rmscene>=0.7.0 waren inkompatibel mit rmc)
183 lines
6.8 KiB
Python
Executable File
183 lines
6.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import sys, os, subprocess, re, shutil, json, tempfile, struct
|
|
from pypdf import PdfReader, PdfWriter
|
|
|
|
INKSCAPE_TIMEOUT = 30
|
|
|
|
def fallback_page(reader, i, path):
|
|
w = PdfWriter()
|
|
w.add_page(reader.pages[i])
|
|
with open(path, 'wb') as fh:
|
|
w.write(fh)
|
|
|
|
tmpdir = sys.argv[1]
|
|
bg_pdf = sys.argv[2]
|
|
output = sys.argv[3]
|
|
|
|
content_file = next(
|
|
(os.path.join(tmpdir, f) for f in os.listdir(tmpdir) if f.endswith('.content')), None
|
|
)
|
|
if not content_file:
|
|
sys.exit(1)
|
|
|
|
with open(content_file) as f:
|
|
data = json.load(f)
|
|
|
|
doc_uuid = os.path.basename(content_file)[:-8]
|
|
rm_dir = os.path.join(tmpdir, doc_uuid)
|
|
page_ids = [p['id'] for p in data.get('cPages', {}).get('pages', [])]
|
|
|
|
page_rm = {}
|
|
for i, pid in enumerate(page_ids):
|
|
rm = os.path.join(rm_dir, pid + '.rm')
|
|
if os.path.exists(rm):
|
|
page_rm[i] = rm
|
|
|
|
if not page_rm:
|
|
sys.exit(1)
|
|
|
|
reader = PdfReader(bg_pdf)
|
|
n_pages = len(reader.pages)
|
|
work = tempfile.mkdtemp()
|
|
parts = []
|
|
|
|
for i in range(n_pages):
|
|
part_out = os.path.join(work, 'p' + str(i) + '.pdf')
|
|
|
|
if i not in page_rm:
|
|
fallback_page(reader, i, part_out)
|
|
parts.append(part_out)
|
|
continue
|
|
|
|
try:
|
|
# PDF-Seitengröße in Punkten
|
|
pdf_w_pt = float(reader.pages[i].mediabox.width)
|
|
pdf_h_pt = float(reader.pages[i].mediabox.height)
|
|
dpi = 150
|
|
png_w = int(round(pdf_w_pt * dpi / 72))
|
|
png_h = int(round(pdf_h_pt * dpi / 72))
|
|
|
|
# Bg-Seite als PNG rendern
|
|
bg_base = os.path.join(work, 'bg' + str(i))
|
|
subprocess.run(
|
|
['pdftoppm', '-r', str(dpi), '-png', '-f', str(i+1), '-l', str(i+1),
|
|
'-singlefile', bg_pdf, bg_base],
|
|
capture_output=True, timeout=20
|
|
)
|
|
bg_png = bg_base + '.png'
|
|
if not os.path.exists(bg_png):
|
|
raise FileNotFoundError('PNG fehlt')
|
|
|
|
# .rm -> SVG
|
|
svg_out = os.path.join(work, 'a' + str(i) + '.svg')
|
|
r = subprocess.run(['rmc', '-t', 'svg', '-o', svg_out, page_rm[i]],
|
|
capture_output=True, timeout=20)
|
|
if r.returncode != 0 or not os.path.exists(svg_out):
|
|
raise RuntimeError('rmc fehlgeschlagen')
|
|
|
|
svg = open(svg_out).read()
|
|
|
|
# Weissen Hintergrund entfernen
|
|
svg = re.sub(
|
|
r'<rect\b[^>]*/?>',
|
|
lambda m: '' if re.search(r'fill\s*[=:]\s*["\']?\s*(?:white|#fff(?:fff)?)', m.group(0), re.I) else m.group(0),
|
|
svg
|
|
)
|
|
|
|
# ViewBox X-Offset auslesen
|
|
vb = re.search(r'viewBox=["\']([^"\']+)["\']', svg)
|
|
vb_vals = [float(x) for x in vb.group(1).split()] if vb else [0, 0, pdf_w_pt, pdf_h_pt]
|
|
vb_x = vb_vals[0]
|
|
vb_y = vb_vals[1]
|
|
|
|
# Inneren SVG-Inhalt extrahieren
|
|
inner_m = re.search(r'<svg[^>]*>(.*)</svg>', svg, re.DOTALL)
|
|
inner = inner_m.group(1) if inner_m else ''
|
|
|
|
# GlyphRange-Highlights (Textmarkierungen) aus .rm lesen
|
|
highlight_svg = ''
|
|
try:
|
|
import rmscene
|
|
RM_SCALE = 72.0 / 226 # rmscene screen-px → SVG-Punkte (wie rmc)
|
|
HIGHLIGHT_COLORS = {
|
|
9: ('rgb(255,235,0)', 0.4), # HIGHLIGHT (gelb)
|
|
3: ('rgb(251,247,25)', 0.4), # YELLOW
|
|
4: ('rgb(0,255,0)', 0.4), # GREEN
|
|
5: ('rgb(255,192,203)', 0.4), # PINK
|
|
6: ('rgb(78,105,201)', 0.4), # BLUE
|
|
7: ('rgb(179,62,57)', 0.4), # RED
|
|
}
|
|
with open(page_rm[i], 'rb') as rmf:
|
|
blocks = list(rmscene.read_blocks(rmf))
|
|
for block in blocks:
|
|
if not (hasattr(block, 'item') and hasattr(block.item, 'value')):
|
|
continue
|
|
glyph = block.item.value
|
|
if not hasattr(glyph, 'rectangles'):
|
|
continue
|
|
try:
|
|
color_id = int(glyph.color)
|
|
except Exception:
|
|
color_id = 9
|
|
fill, opacity = HIGHLIGHT_COLORS.get(color_id, ('rgb(255,235,0)', 0.4))
|
|
for rect in glyph.rectangles:
|
|
highlight_svg += (
|
|
f'<rect x="{rect.x * RM_SCALE:.3f}" y="{rect.y * RM_SCALE:.3f}" '
|
|
f'width="{rect.w * RM_SCALE:.3f}" height="{rect.h * RM_SCALE:.3f}" '
|
|
f'fill="{fill}" fill-opacity="{opacity}" stroke="none"/>\n'
|
|
)
|
|
except Exception as e:
|
|
print(f' GlyphRange Fehler: {e}', flush=True)
|
|
|
|
# Highlights VOR den Stift-Annotationen einblenden
|
|
if highlight_svg:
|
|
print(f' {highlight_svg.count("<rect")} GlyphRect(s) für Seite {i+1}', flush=True)
|
|
inner = highlight_svg + inner
|
|
|
|
# Annotation-Koordinaten sind in PDF-Punkten
|
|
# Skalierung: PDF-Punkte -> PNG-Pixel
|
|
pts_to_px = png_w / pdf_w_pt
|
|
# reMarkable Seitenrand proportional zur PDF-Breite (empirisch: 75.5pt bei A4)
|
|
rm_margin_left = 75.5 * pdf_w_pt / 595.275
|
|
# reMarkable Seitenrand proportional zur PDF-Breite (empirisch: 75.5pt bei A4)
|
|
rm_margin_left = 75.5 * pdf_w_pt / 595.275
|
|
|
|
# Composite SVG
|
|
comp_svg = os.path.join(work, 'c' + str(i) + '.svg')
|
|
with open(comp_svg, 'w') as fh:
|
|
fh.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
fh.write('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"')
|
|
fh.write(' width="' + str(pdf_w_pt) + 'pt"')
|
|
fh.write(' height="' + str(pdf_h_pt) + 'pt"')
|
|
fh.write(' viewBox="0 0 ' + str(png_w) + ' ' + str(png_h) + '">\n')
|
|
fh.write(' <image xlink:href="file://' + bg_png + '" href="file://' + bg_png + '"')
|
|
fh.write(' width="' + str(png_w) + '" height="' + str(png_h) + '"/>\n')
|
|
fh.write(' <g transform="scale(' + str(pts_to_px) + ',' + str(pts_to_px) + ')')
|
|
fh.write(' translate(' + str(-vb_x + rm_margin_left) + ',' + str(-vb_y) + ')">\n')
|
|
fh.write(' ' + inner + '\n')
|
|
fh.write(' </g>\n')
|
|
fh.write('</svg>')
|
|
|
|
# Composite -> PDF
|
|
r = subprocess.run(
|
|
['inkscape', comp_svg, '--export-type=pdf', '--export-filename=' + part_out],
|
|
capture_output=True, timeout=INKSCAPE_TIMEOUT
|
|
)
|
|
if r.returncode != 0 or not os.path.exists(part_out) or os.path.getsize(part_out) < 100:
|
|
raise RuntimeError('inkscape fehlgeschlagen')
|
|
|
|
parts.append(part_out)
|
|
print(' Seite ' + str(i+1) + ' mit Annotation', flush=True)
|
|
|
|
except Exception as e:
|
|
print(' Seite ' + str(i+1) + ' Fallback: ' + str(e), flush=True)
|
|
fallback_page(reader, i, part_out)
|
|
parts.append(part_out)
|
|
|
|
if len(parts) == 1:
|
|
shutil.copy(parts[0], output)
|
|
elif parts:
|
|
subprocess.run(['pdfunite'] + parts + [output], check=True)
|
|
|
|
shutil.rmtree(work)
|