mirror of
https://github.com/byoungd/up.git
synced 2026-10-02 08:04:40 +08:00
947 lines
37 KiB
Python
947 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import html
|
||
from io import BytesIO
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import urllib.parse
|
||
import zipfile
|
||
from dataclasses import dataclass
|
||
from pathlib import Path, PurePosixPath
|
||
from xml.etree import ElementTree as ET
|
||
|
||
from PIL import Image as PILImage
|
||
from pypdf import PdfReader, __version__ as PYPDF_VERSION
|
||
from reportlab.lib import colors
|
||
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
|
||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||
from reportlab.lib.utils import ImageReader
|
||
from reportlab.lib.units import inch, mm
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
from reportlab.pdfgen import canvas
|
||
from reportlab.platypus import (
|
||
BaseDocTemplate,
|
||
Frame,
|
||
HRFlowable,
|
||
Image,
|
||
KeepTogether,
|
||
ListFlowable,
|
||
ListItem,
|
||
NextPageTemplate,
|
||
PageBreak,
|
||
PageTemplate,
|
||
Paragraph,
|
||
Spacer,
|
||
Table,
|
||
TableStyle,
|
||
XPreformatted,
|
||
)
|
||
from reportlab.platypus.tableofcontents import TableOfContents
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DOWNLOADS = ROOT / "docs/public/downloads"
|
||
PUBLIC_OUTPUT = DOWNLOADS
|
||
LOCAL_OUTPUT = ROOT / "output/pdf"
|
||
CHECK_ONLY = "--check" in sys.argv
|
||
CHECK_EXACT = "--check-exact" in sys.argv
|
||
PAGE_SIZE = (6 * inch, 9.6 * inch)
|
||
PAGE_WIDTH, PAGE_HEIGHT = PAGE_SIZE
|
||
LEFT_MARGIN = 17 * mm
|
||
RIGHT_MARGIN = 15 * mm
|
||
TOP_MARGIN = 19 * mm
|
||
BOTTOM_MARGIN = 18 * mm
|
||
CONTENT_WIDTH = PAGE_WIDTH - LEFT_MARGIN - RIGHT_MARGIN
|
||
CONTENT_HEIGHT = PAGE_HEIGHT - TOP_MARGIN - BOTTOM_MARGIN
|
||
NS = {"x": "http://www.w3.org/1999/xhtml"}
|
||
ZH_REGULAR_FONT = ROOT / "book-assets/fonts/NotoSerifSC-LifeLevelUp-Regular.ttf"
|
||
ZH_BOLD_FONT = ROOT / "book-assets/fonts/NotoSerifSC-LifeLevelUp-Bold.ttf"
|
||
ZH_IPA_FONT = ROOT / "book-assets/fonts/NotoSans-LifeLevelUp-IPA.ttf"
|
||
IPA_FALLBACK_CHARACTERS = set("ɪʌː")
|
||
REQUIRED_SPACING_CHARACTERS = set(" \u00a0")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Edition:
|
||
key: str
|
||
language: str
|
||
epub_file: str
|
||
pdf_file: str
|
||
title: str
|
||
subtitle: str
|
||
author: str
|
||
contents: str
|
||
cover: Path
|
||
description: str
|
||
body_font: str
|
||
bold_font: str
|
||
italic_font: str
|
||
mono_font: str
|
||
|
||
|
||
EDITIONS = [
|
||
Edition(
|
||
key="zh",
|
||
language="zh-CN",
|
||
epub_file="life-level-up-guide-zh.epub",
|
||
pdf_file="life-level-up-guide-zh.pdf",
|
||
title="人生进阶指南",
|
||
subtitle="AI 时代终身学习指南",
|
||
author="韩先凯",
|
||
contents="目录",
|
||
cover=ROOT / "docs/public/assets/cover-portrait.png",
|
||
description="从英语、AI、真实项目与人生低谷出发,建立能够复测、迁移、恢复并承担责任的终身学习系统。",
|
||
body_font="NotoSerifSC-LifeLevelUp",
|
||
bold_font="NotoSerifSC-LifeLevelUp-Bold",
|
||
italic_font="NotoSerifSC-LifeLevelUp",
|
||
mono_font="NotoSerifSC-LifeLevelUp",
|
||
),
|
||
Edition(
|
||
key="en",
|
||
language="en-US",
|
||
epub_file="life-level-up-guide-en.epub",
|
||
pdf_file="life-level-up-guide-en.pdf",
|
||
title="Life Level-up Guide",
|
||
subtitle="Lifelong Learning Guide for the AI Era",
|
||
author="Han Xiankai",
|
||
contents="Contents",
|
||
cover=ROOT / "docs/public/assets/cover-portrait-en.png",
|
||
description="A lifelong-learning system for English, AI, real projects, difficult seasons, evidence, transfer, recovery, and responsibility.",
|
||
body_font="Times-Roman",
|
||
bold_font="Times-Bold",
|
||
italic_font="Times-Italic",
|
||
mono_font="Courier",
|
||
),
|
||
]
|
||
|
||
|
||
def sha256_bytes(value: bytes) -> str:
|
||
return hashlib.sha256(value).hexdigest()
|
||
|
||
|
||
def strip_namespace(tag: str) -> str:
|
||
return tag.rsplit("}", 1)[-1]
|
||
|
||
|
||
def clean_text(value: str | None) -> str:
|
||
return (value or "").replace("\u200b", "").replace("\ufeff", "")
|
||
|
||
|
||
def dereference(value):
|
||
return value.get_object() if hasattr(value, "get_object") else value
|
||
|
||
|
||
def normalized_font_name(value: object) -> str:
|
||
name = str(value or "").removeprefix("/")
|
||
return re.sub(r"^[A-Z]{6}\+", "", name)
|
||
|
||
|
||
def outline_entries(items, level: int = 0) -> list[dict[str, object]]:
|
||
entries = []
|
||
for item in items:
|
||
if isinstance(item, list):
|
||
entries.extend(outline_entries(item, level + 1))
|
||
else:
|
||
entries.append({"level": level, "title": clean_text(getattr(item, "title", str(item)))})
|
||
return entries
|
||
|
||
|
||
def pdf_semantics(reader: PdfReader) -> dict:
|
||
pages = []
|
||
fonts = set()
|
||
link_count = 0
|
||
image_count = 0
|
||
|
||
for page in reader.pages:
|
||
resources = dereference(page.get("/Resources", {})) or {}
|
||
page_fonts = dereference(resources.get("/Font", {})) or {}
|
||
for font_reference in page_fonts.values():
|
||
font = dereference(font_reference)
|
||
descriptor = dereference(font.get("/FontDescriptor")) if font.get("/FontDescriptor") else None
|
||
if descriptor is None and font.get("/DescendantFonts"):
|
||
descendants = dereference(font["/DescendantFonts"])
|
||
if descendants:
|
||
descendant = dereference(descendants[0])
|
||
descriptor = dereference(descendant.get("/FontDescriptor")) if descendant.get("/FontDescriptor") else None
|
||
embedded = bool(
|
||
descriptor
|
||
and any(key in descriptor for key in ("/FontFile", "/FontFile2", "/FontFile3"))
|
||
)
|
||
fonts.add(
|
||
(
|
||
normalized_font_name(font.get("/BaseFont")),
|
||
str(font.get("/Subtype", "")).removeprefix("/"),
|
||
embedded,
|
||
)
|
||
)
|
||
|
||
links = []
|
||
for annotation_reference in page.get("/Annots", []):
|
||
annotation = dereference(annotation_reference)
|
||
if str(annotation.get("/Subtype", "")) != "/Link":
|
||
continue
|
||
action = dereference(annotation.get("/A")) if annotation.get("/A") else None
|
||
if action and action.get("/URI"):
|
||
kind = "uri"
|
||
target = str(action["/URI"])
|
||
else:
|
||
kind = "internal"
|
||
target = ""
|
||
rectangle = [round(float(value), 3) for value in annotation.get("/Rect", [])]
|
||
links.append({"kind": kind, "target": target, "rect": rectangle})
|
||
links.sort(key=lambda value: json.dumps(value, ensure_ascii=False, sort_keys=True))
|
||
link_count += len(links)
|
||
|
||
images = []
|
||
for image in page.images:
|
||
width, height = image.image.size
|
||
images.append({"width": width, "height": height, "mode": image.image.mode})
|
||
images.sort(key=lambda value: (value["width"], value["height"], value["mode"]))
|
||
image_count += len(images)
|
||
|
||
pages.append(
|
||
{
|
||
"width": round(float(page.mediabox.width), 3),
|
||
"height": round(float(page.mediabox.height), 3),
|
||
"textSha256": sha256_bytes(clean_text(page.extract_text()).encode("utf-8")),
|
||
"links": links,
|
||
"images": images,
|
||
}
|
||
)
|
||
|
||
outline = outline_entries(reader.outline)
|
||
value = {
|
||
"pages": pages,
|
||
"outline": outline,
|
||
"fonts": [
|
||
{"name": name, "subtype": subtype, "embedded": embedded}
|
||
for name, subtype, embedded in sorted(fonts)
|
||
],
|
||
}
|
||
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||
return {
|
||
"semanticSha256": sha256_bytes(encoded),
|
||
"outlineEntries": len(outline),
|
||
"linkAnnotations": link_count,
|
||
"images": image_count,
|
||
"fonts": value["fonts"],
|
||
}
|
||
|
||
|
||
def formatted_text(value: str | None, edition: Edition) -> str:
|
||
text = clean_text(value)
|
||
if edition.key != "zh" or not any(character in IPA_FALLBACK_CHARACTERS for character in text):
|
||
return html.escape(text)
|
||
parts = []
|
||
buffer = []
|
||
for character in text:
|
||
if character in IPA_FALLBACK_CHARACTERS:
|
||
if buffer:
|
||
parts.append(html.escape("".join(buffer)))
|
||
buffer = []
|
||
parts.append(f'<font name="NotoSans-LifeLevelUp-IPA">{html.escape(character)}</font>')
|
||
else:
|
||
buffer.append(character)
|
||
if buffer:
|
||
parts.append(html.escape("".join(buffer)))
|
||
return "".join(parts)
|
||
|
||
|
||
def safe_anchor(value: str) -> str:
|
||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") or "section"
|
||
|
||
|
||
def heading_anchor(file: str, value: str) -> str:
|
||
digest = hashlib.sha1(clean_text(value).encode("utf-8")).hexdigest()[:12]
|
||
return f"{safe_anchor(Path(file).stem)}-{digest}"
|
||
|
||
|
||
def fit_running_header(text: str, font_name: str, font_size: float, max_width: float) -> str:
|
||
normalized = " ".join(clean_text(text).split())
|
||
if pdfmetrics.stringWidth(normalized, font_name, font_size) <= max_width:
|
||
return normalized
|
||
suffix = "..."
|
||
if " " in normalized:
|
||
parts = normalized.split()
|
||
while parts and pdfmetrics.stringWidth(" ".join(parts) + suffix, font_name, font_size) > max_width:
|
||
parts.pop()
|
||
return (" ".join(parts) + suffix) if parts else suffix
|
||
parts = list(normalized)
|
||
while parts and pdfmetrics.stringWidth("".join(parts) + suffix, font_name, font_size) > max_width:
|
||
parts.pop()
|
||
return ("".join(parts) + suffix) if parts else suffix
|
||
|
||
|
||
class InvariantCanvas(canvas.Canvas):
|
||
def __init__(self, *args, metadata: dict[str, str], **kwargs):
|
||
kwargs["invariant"] = 1
|
||
kwargs["pageCompression"] = 1
|
||
kwargs["pdfVersion"] = (1, 7)
|
||
super().__init__(*args, **kwargs)
|
||
self.setTitle(metadata["title"])
|
||
self.setAuthor(metadata["author"])
|
||
self.setSubject(metadata["subject"])
|
||
self.setCreator("Life Level-up Guide deterministic PDF builder")
|
||
|
||
|
||
class BookDocTemplate(BaseDocTemplate):
|
||
def __init__(self, filename: str, edition: Edition, cover_path: Path):
|
||
super().__init__(
|
||
filename,
|
||
pagesize=PAGE_SIZE,
|
||
leftMargin=LEFT_MARGIN,
|
||
rightMargin=RIGHT_MARGIN,
|
||
topMargin=TOP_MARGIN,
|
||
bottomMargin=BOTTOM_MARGIN,
|
||
title=edition.title,
|
||
author=edition.author,
|
||
subject=edition.description,
|
||
creator="Life Level-up Guide deterministic PDF builder",
|
||
)
|
||
self.edition = edition
|
||
self.cover_path = cover_path
|
||
self.current_heading = edition.title
|
||
body_frame = Frame(
|
||
LEFT_MARGIN,
|
||
BOTTOM_MARGIN,
|
||
CONTENT_WIDTH,
|
||
CONTENT_HEIGHT,
|
||
id="body-frame",
|
||
leftPadding=0,
|
||
rightPadding=0,
|
||
topPadding=0,
|
||
bottomPadding=0,
|
||
)
|
||
self.addPageTemplates(
|
||
[
|
||
PageTemplate(id="cover", frames=[body_frame], onPage=self.draw_cover),
|
||
PageTemplate(id="body", frames=[body_frame], onPageEnd=self.draw_body_page),
|
||
]
|
||
)
|
||
|
||
def draw_cover(self, canv: canvas.Canvas, _doc) -> None:
|
||
canv.saveState()
|
||
with PILImage.open(self.cover_path) as source:
|
||
width, height = source.size
|
||
scale = min(PAGE_WIDTH / width, PAGE_HEIGHT / height)
|
||
draw_width = width * scale
|
||
draw_height = height * scale
|
||
canv.drawImage(
|
||
ImageReader(BytesIO(self.cover_path.read_bytes())),
|
||
(PAGE_WIDTH - draw_width) / 2,
|
||
(PAGE_HEIGHT - draw_height) / 2,
|
||
width=draw_width,
|
||
height=draw_height,
|
||
preserveAspectRatio=True,
|
||
mask="auto",
|
||
)
|
||
canv.restoreState()
|
||
|
||
def draw_body_page(self, canv: canvas.Canvas, _doc) -> None:
|
||
page_number = canv.getPageNumber() - 1
|
||
canv.saveState()
|
||
canv.setStrokeColor(colors.HexColor("#cfd7d1"))
|
||
canv.setLineWidth(0.5)
|
||
canv.line(LEFT_MARGIN, PAGE_HEIGHT - 12 * mm, PAGE_WIDTH - RIGHT_MARGIN, PAGE_HEIGHT - 12 * mm)
|
||
canv.setFillColor(colors.HexColor("#5f6c68"))
|
||
font_size = 7.5
|
||
canv.setFont(self.edition.body_font, font_size)
|
||
header = self.current_heading if page_number > 2 else self.edition.title
|
||
header = fit_running_header(header, self.edition.body_font, font_size, CONTENT_WIDTH)
|
||
canv.drawString(LEFT_MARGIN, PAGE_HEIGHT - 9.2 * mm, header)
|
||
canv.drawCentredString(PAGE_WIDTH / 2, 9 * mm, str(page_number))
|
||
canv.restoreState()
|
||
|
||
def afterFlowable(self, flowable) -> None:
|
||
if not isinstance(flowable, Paragraph) or not hasattr(flowable, "toc_level"):
|
||
return
|
||
level = flowable.toc_level
|
||
text = flowable.getPlainText()
|
||
key = flowable.bookmark
|
||
self.canv.bookmarkPage(key)
|
||
try:
|
||
self.canv.addOutlineEntry(text, key, level=level, closed=level == 0)
|
||
except ValueError:
|
||
pass
|
||
if level == 0:
|
||
self.notify("TOCEntry", (0, text, self.page, key))
|
||
self.current_heading = text
|
||
|
||
|
||
def style_sheet(edition: Edition) -> dict[str, ParagraphStyle]:
|
||
base = getSampleStyleSheet()
|
||
justify = TA_JUSTIFY if edition.key == "zh" else TA_LEFT
|
||
styles = {
|
||
"body": ParagraphStyle(
|
||
"Body",
|
||
parent=base["BodyText"],
|
||
fontName=edition.body_font,
|
||
fontSize=9.4,
|
||
leading=15.2,
|
||
alignment=justify,
|
||
textColor=colors.HexColor("#1d2926"),
|
||
spaceAfter=6.5,
|
||
splitLongWords=True,
|
||
),
|
||
"h1": ParagraphStyle(
|
||
"Heading1",
|
||
parent=base["Heading1"],
|
||
fontName=edition.bold_font,
|
||
fontSize=21,
|
||
leading=27,
|
||
textColor=colors.HexColor("#1d2926"),
|
||
spaceAfter=12,
|
||
keepWithNext=True,
|
||
),
|
||
"h2": ParagraphStyle(
|
||
"Heading2",
|
||
parent=base["Heading2"],
|
||
fontName=edition.bold_font,
|
||
fontSize=14,
|
||
leading=19,
|
||
textColor=colors.HexColor("#176f5b"),
|
||
spaceBefore=15,
|
||
spaceAfter=7,
|
||
keepWithNext=True,
|
||
),
|
||
"h3": ParagraphStyle(
|
||
"Heading3",
|
||
parent=base["Heading3"],
|
||
fontName=edition.bold_font,
|
||
fontSize=11.2,
|
||
leading=15,
|
||
textColor=colors.HexColor("#31443f"),
|
||
spaceBefore=10,
|
||
spaceAfter=5,
|
||
keepWithNext=True,
|
||
),
|
||
"h4": ParagraphStyle(
|
||
"Heading4",
|
||
parent=base["Heading4"],
|
||
fontName=edition.bold_font,
|
||
fontSize=9.8,
|
||
leading=13.5,
|
||
textColor=colors.HexColor("#485652"),
|
||
spaceBefore=8,
|
||
spaceAfter=4,
|
||
keepWithNext=True,
|
||
),
|
||
"quote": ParagraphStyle(
|
||
"Quote",
|
||
parent=base["BodyText"],
|
||
fontName=edition.body_font,
|
||
fontSize=9,
|
||
leading=14.5,
|
||
leftIndent=10,
|
||
rightIndent=5,
|
||
borderWidth=0,
|
||
borderPadding=7,
|
||
backColor=colors.HexColor("#eef2ef"),
|
||
textColor=colors.HexColor("#485652"),
|
||
spaceBefore=5,
|
||
spaceAfter=8,
|
||
),
|
||
"code": ParagraphStyle(
|
||
"Code",
|
||
parent=base["Code"],
|
||
fontName=edition.mono_font,
|
||
fontSize=7.3,
|
||
leading=10.2,
|
||
leftIndent=4,
|
||
rightIndent=4,
|
||
borderPadding=6,
|
||
backColor=colors.HexColor("#f2f3f2"),
|
||
textColor=colors.HexColor("#263431"),
|
||
spaceBefore=5,
|
||
spaceAfter=7,
|
||
splitLongWords=True,
|
||
),
|
||
"caption": ParagraphStyle(
|
||
"Caption",
|
||
parent=base["BodyText"],
|
||
fontName=edition.body_font,
|
||
fontSize=7.5,
|
||
leading=10,
|
||
alignment=TA_CENTER,
|
||
textColor=colors.HexColor("#6b7773"),
|
||
spaceBefore=2,
|
||
spaceAfter=8,
|
||
),
|
||
"title": ParagraphStyle(
|
||
"TitlePage",
|
||
parent=base["Title"],
|
||
fontName=edition.bold_font,
|
||
fontSize=27,
|
||
leading=34,
|
||
alignment=TA_CENTER,
|
||
textColor=colors.HexColor("#1d2926"),
|
||
spaceAfter=16,
|
||
),
|
||
"subtitle": ParagraphStyle(
|
||
"Subtitle",
|
||
parent=base["BodyText"],
|
||
fontName=edition.body_font,
|
||
fontSize=13,
|
||
leading=18,
|
||
alignment=TA_CENTER,
|
||
textColor=colors.HexColor("#176f5b"),
|
||
spaceAfter=25,
|
||
),
|
||
"center": ParagraphStyle(
|
||
"Center",
|
||
parent=base["BodyText"],
|
||
fontName=edition.body_font,
|
||
fontSize=10,
|
||
leading=15,
|
||
alignment=TA_CENTER,
|
||
textColor=colors.HexColor("#485652"),
|
||
spaceAfter=9,
|
||
),
|
||
}
|
||
return styles
|
||
|
||
|
||
def inline_markup(element: ET.Element, edition: Edition, current_file: str, h1_anchors: dict[str, str]) -> str:
|
||
def visit(node: ET.Element) -> str:
|
||
tag = strip_namespace(node.tag)
|
||
content = formatted_text(node.text, edition)
|
||
for child in list(node):
|
||
content += visit(child)
|
||
content += formatted_text(child.tail, edition)
|
||
|
||
if tag in {"strong", "b"}:
|
||
return f"<b>{content}</b>"
|
||
if tag in {"em", "i"}:
|
||
return f"<i>{content}</i>"
|
||
if tag == "code":
|
||
return f'<font name="{edition.mono_font}">{content}</font>'
|
||
if tag == "br":
|
||
return "<br/>"
|
||
if tag == "a":
|
||
href = node.attrib.get("href", "")
|
||
if href.startswith("http") or href.startswith("mailto:"):
|
||
return f'<link href="{html.escape(href, quote=True)}" color="#176f5b">{content}</link>'
|
||
target_file, _, raw_hash = href.partition("#")
|
||
target_file = target_file or current_file
|
||
anchor = heading_anchor(target_file, urllib.parse.unquote(raw_hash)) if raw_hash else h1_anchors.get(target_file)
|
||
if anchor:
|
||
return f'<link href="#{anchor}" color="#176f5b">{content}</link>'
|
||
return content
|
||
return content
|
||
|
||
return visit(element)
|
||
|
||
|
||
def paragraph_from(element: ET.Element, style: ParagraphStyle, edition: Edition, current_file: str, h1_anchors: dict[str, str]) -> Paragraph | None:
|
||
markup = inline_markup(element, edition, current_file, h1_anchors).strip()
|
||
if not markup:
|
||
return None
|
||
return Paragraph(markup, style)
|
||
|
||
|
||
def build_list(element: ET.Element, styles, edition: Edition, current_file: str, h1_anchors: dict[str, str]) -> ListFlowable:
|
||
ordered = strip_namespace(element.tag) == "ol"
|
||
items = []
|
||
for li in element:
|
||
if strip_namespace(li.tag) != "li":
|
||
continue
|
||
item_flowables = []
|
||
inline_parts = []
|
||
if clean_text(li.text).strip():
|
||
inline_parts.append(formatted_text(li.text, edition))
|
||
for child in li:
|
||
tag = strip_namespace(child.tag)
|
||
if tag in {"ul", "ol"}:
|
||
if inline_parts:
|
||
item_flowables.append(Paragraph("".join(inline_parts), styles["body"]))
|
||
inline_parts = []
|
||
item_flowables.append(build_list(child, styles, edition, current_file, h1_anchors))
|
||
elif tag == "p":
|
||
if inline_parts:
|
||
item_flowables.append(Paragraph("".join(inline_parts), styles["body"]))
|
||
inline_parts = []
|
||
paragraph = paragraph_from(child, styles["body"], edition, current_file, h1_anchors)
|
||
if paragraph:
|
||
item_flowables.append(paragraph)
|
||
else:
|
||
inline_parts.append(inline_markup(child, edition, current_file, h1_anchors))
|
||
if clean_text(child.tail).strip():
|
||
inline_parts.append(formatted_text(child.tail, edition))
|
||
if inline_parts:
|
||
item_flowables.append(Paragraph("".join(inline_parts), styles["body"]))
|
||
if not item_flowables:
|
||
item_flowables.append(Paragraph("", styles["body"]))
|
||
items.append(ListItem(item_flowables, leftIndent=12))
|
||
list_options = {
|
||
"bulletType": "1" if ordered else "bullet",
|
||
"leftIndent": 18,
|
||
"bulletFontName": edition.body_font,
|
||
"bulletFontSize": 8,
|
||
"spaceAfter": 6,
|
||
}
|
||
if ordered:
|
||
list_options["start"] = "1"
|
||
return ListFlowable(
|
||
items,
|
||
**list_options,
|
||
)
|
||
|
||
|
||
def build_table(element: ET.Element, styles, edition: Edition, current_file: str, h1_anchors: dict[str, str]) -> Table:
|
||
rows = []
|
||
header_rows = 0
|
||
for section in list(element):
|
||
section_tag = strip_namespace(section.tag)
|
||
row_elements = [section] if section_tag == "tr" else [child for child in section if strip_namespace(child.tag) == "tr"]
|
||
for row_element in row_elements:
|
||
row = []
|
||
is_header = section_tag == "thead"
|
||
for cell in row_element:
|
||
if strip_namespace(cell.tag) not in {"th", "td"}:
|
||
continue
|
||
markup = inline_markup(cell, edition, current_file, h1_anchors).strip() or " "
|
||
cell_style = ParagraphStyle(
|
||
f"Cell-{len(rows)}-{len(row)}",
|
||
parent=styles["body"],
|
||
fontName=edition.bold_font if strip_namespace(cell.tag) == "th" else edition.body_font,
|
||
fontSize=7.2,
|
||
leading=9.3,
|
||
alignment=TA_LEFT,
|
||
spaceAfter=0,
|
||
)
|
||
row.append(Paragraph(markup, cell_style))
|
||
if row:
|
||
rows.append(row)
|
||
if is_header:
|
||
header_rows += 1
|
||
columns = max((len(row) for row in rows), default=1)
|
||
for row in rows:
|
||
row.extend([Paragraph(" ", styles["body"])] * (columns - len(row)))
|
||
table = Table(rows, colWidths=[CONTENT_WIDTH / columns] * columns, repeatRows=header_rows, hAlign="LEFT")
|
||
table.setStyle(
|
||
TableStyle(
|
||
[
|
||
("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#9aa49f")),
|
||
("BACKGROUND", (0, 0), (-1, max(header_rows - 1, 0)), colors.HexColor("#e9efeb")),
|
||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
||
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||
]
|
||
)
|
||
)
|
||
return table
|
||
|
||
|
||
def image_flowables(element: ET.Element, extracted_root: Path, current_file: str, styles) -> list:
|
||
src = element.attrib.get("src", "")
|
||
if not src or src.startswith("http"):
|
||
return []
|
||
image_path = (extracted_root / "OEBPS/text" / current_file).parent.joinpath(src).resolve()
|
||
if not image_path.exists():
|
||
return []
|
||
raster_dir = extracted_root / "pdf-raster"
|
||
raster_dir.mkdir(parents=True, exist_ok=True)
|
||
raster_path = raster_dir / f"{hashlib.sha256(image_path.read_bytes()).hexdigest()}.jpg"
|
||
if not raster_path.exists():
|
||
script = (
|
||
'import sharp from "sharp"; '
|
||
'await sharp(process.argv[1], { density: 180 })'
|
||
'.flatten({ background: "#ffffff" })'
|
||
'.resize({ width: 1400, withoutEnlargement: true })'
|
||
'.jpeg({ quality: 84, mozjpeg: true })'
|
||
'.toFile(process.argv[2]);'
|
||
)
|
||
subprocess.run(
|
||
["node", "--input-type=module", "-e", script, str(image_path), str(raster_path)],
|
||
cwd=ROOT,
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
image_path = raster_path
|
||
image_bytes = image_path.read_bytes()
|
||
with PILImage.open(BytesIO(image_bytes)) as source:
|
||
width, height = source.size
|
||
scale = min(CONTENT_WIDTH / width, (PAGE_HEIGHT - 55 * mm) / height, 1)
|
||
flowables = [Image(BytesIO(image_bytes), width=width * scale, height=height * scale)]
|
||
alt = element.attrib.get("alt", "").strip()
|
||
if alt:
|
||
flowables.append(Paragraph(html.escape(alt), styles["caption"]))
|
||
return flowables
|
||
|
||
|
||
def convert_children(parent: ET.Element, extracted_root: Path, current_file: str, styles, edition: Edition, h1_anchors: dict[str, str], level_state: dict[str, int]) -> list:
|
||
flowables = []
|
||
for element in list(parent):
|
||
tag = strip_namespace(element.tag)
|
||
if tag in {"h1", "h2", "h3", "h4"}:
|
||
level = int(tag[1]) - 1
|
||
text = "".join(element.itertext()).replace("\u200b", "").strip()
|
||
raw_id = element.attrib.get("id", text)
|
||
bookmark = heading_anchor(current_file, raw_id)
|
||
markup = f'<a name="{bookmark}"/>{html.escape(text)}'
|
||
paragraph = Paragraph(markup, styles[tag])
|
||
paragraph.toc_level = level
|
||
paragraph.bookmark = bookmark
|
||
flowables.append(paragraph)
|
||
level_state["headings"] += 1
|
||
elif tag == "p":
|
||
embedded_images = [child for child in list(element) if strip_namespace(child.tag) == "img"]
|
||
if embedded_images:
|
||
for image_element in embedded_images:
|
||
flowables.extend(image_flowables(image_element, extracted_root, current_file, styles))
|
||
else:
|
||
paragraph = paragraph_from(element, styles["body"], edition, current_file, h1_anchors)
|
||
if paragraph:
|
||
flowables.append(paragraph)
|
||
elif tag == "blockquote":
|
||
paragraph = paragraph_from(element, styles["quote"], edition, current_file, h1_anchors)
|
||
if paragraph:
|
||
flowables.append(paragraph)
|
||
elif tag in {"ul", "ol"}:
|
||
flowables.append(build_list(element, styles, edition, current_file, h1_anchors))
|
||
elif tag == "table":
|
||
flowables.append(build_table(element, styles, edition, current_file, h1_anchors))
|
||
flowables.append(Spacer(1, 6))
|
||
elif tag == "pre":
|
||
text = "".join(element.itertext()).replace("\t", " ")
|
||
flowables.append(XPreformatted(html.escape(text), styles["code"]))
|
||
elif tag == "img":
|
||
flowables.extend(image_flowables(element, extracted_root, current_file, styles))
|
||
elif tag == "hr":
|
||
flowables.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cfd7d1"), spaceBefore=6, spaceAfter=8))
|
||
else:
|
||
if tag in {"figure", "section", "div", "main", "article", "details"}:
|
||
flowables.extend(convert_children(element, extracted_root, current_file, styles, edition, h1_anchors, level_state))
|
||
elif tag == "figcaption":
|
||
paragraph = paragraph_from(element, styles["caption"], edition, current_file, h1_anchors)
|
||
if paragraph:
|
||
flowables.append(paragraph)
|
||
return flowables
|
||
|
||
|
||
def inspect_pdf(edition: Edition, target: Path, source_epub_sha256: str, chapter_count: int) -> dict:
|
||
reader = PdfReader(str(target))
|
||
if reader.is_encrypted:
|
||
raise ValueError(f"{edition.pdf_file}: PDF must not be encrypted")
|
||
if len(reader.pages) < 100:
|
||
raise ValueError(f"{edition.pdf_file}: expected at least 100 pages, found {len(reader.pages)}")
|
||
metadata_values = reader.metadata or {}
|
||
if metadata_values.get("/Title") != edition.title or metadata_values.get("/Author") != edition.author:
|
||
raise ValueError(f"{edition.pdf_file}: metadata mismatch")
|
||
first_text_page = "".join(page.extract_text() or "" for page in reader.pages[1:6])
|
||
if edition.title not in first_text_page or edition.contents not in first_text_page:
|
||
raise ValueError(f"{edition.pdf_file}: title or contents text missing")
|
||
final_bytes = target.read_bytes()
|
||
if len(final_bytes) > 8_000_000:
|
||
raise ValueError(f"{edition.pdf_file}: PDF exceeds 8MB budget")
|
||
semantics = pdf_semantics(reader)
|
||
if edition.key == "zh":
|
||
embedded_fonts = {font["name"] for font in semantics["fonts"] if font["embedded"]}
|
||
required_fonts = {"NotoSerifSC-Regular", "NotoSerifSC-Bold", "NotoSans-Regular"}
|
||
missing_fonts = sorted(required_fonts - embedded_fonts)
|
||
if missing_fonts:
|
||
raise ValueError(f"{edition.pdf_file}: missing embedded fonts: {', '.join(missing_fonts)}")
|
||
return {
|
||
"file": edition.pdf_file,
|
||
"language": edition.language,
|
||
"pages": len(reader.pages),
|
||
"chapters": chapter_count,
|
||
"bytes": len(final_bytes),
|
||
"sha256": sha256_bytes(final_bytes),
|
||
"sourceEpubSha256": source_epub_sha256,
|
||
**semantics,
|
||
}
|
||
|
||
|
||
def build_pdf(edition: Edition, target: Path, temp_root: Path) -> dict:
|
||
epub_path = DOWNLOADS / edition.epub_file
|
||
extracted_root = temp_root / edition.key
|
||
with zipfile.ZipFile(epub_path) as archive:
|
||
archive.extractall(extracted_root)
|
||
opf = ET.parse(extracted_root / "OEBPS/package.opf").getroot()
|
||
ns_opf = {"o": "http://www.idpf.org/2007/opf", "dc": "http://purl.org/dc/elements/1.1/"}
|
||
modified = opf.find("o:metadata/o:meta[@property='dcterms:modified']", ns_opf)
|
||
publication_date = (modified.text or "").split("T", 1)[0] if modified is not None else ""
|
||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", publication_date):
|
||
raise ValueError(f"{edition.pdf_file}: EPUB has no valid dcterms:modified date")
|
||
manifest = {
|
||
item.attrib["id"]: item.attrib["href"]
|
||
for item in opf.findall("o:manifest/o:item", ns_opf)
|
||
}
|
||
spine_files = [manifest[item.attrib["idref"]] for item in opf.findall("o:spine/o:itemref", ns_opf)]
|
||
chapter_files = [file.removeprefix("text/") for file in spine_files if file.startswith("text/chapter-")]
|
||
if not chapter_files:
|
||
raise ValueError(f"{edition.pdf_file}: EPUB contains no manuscript chapters")
|
||
|
||
parsed = {}
|
||
h1_anchors = {}
|
||
for file in chapter_files:
|
||
root = ET.parse(extracted_root / "OEBPS/text" / file).getroot()
|
||
parsed[file] = root
|
||
h1 = root.find(".//x:h1", NS)
|
||
raw_id = h1.attrib.get("id", h1.text or file) if h1 is not None else file
|
||
h1_anchors[file] = heading_anchor(file, raw_id)
|
||
|
||
if edition.key == "zh":
|
||
glyphs = pdfmetrics.getFont(edition.body_font).face.charToGlyph
|
||
fallback_glyphs = pdfmetrics.getFont("NotoSans-LifeLevelUp-IPA").face.charToGlyph
|
||
used_characters = set(edition.title + edition.subtitle + edition.author + edition.contents + edition.description)
|
||
for root in parsed.values():
|
||
for text in root.itertext():
|
||
used_characters.update(clean_text(text))
|
||
missing = sorted(
|
||
character
|
||
for character in used_characters
|
||
if (
|
||
(character.isprintable() and not character.isspace())
|
||
or character in REQUIRED_SPACING_CHARACTERS
|
||
)
|
||
and ord(character) not in glyphs
|
||
and ord(character) not in fallback_glyphs
|
||
)
|
||
if missing:
|
||
raise ValueError(
|
||
f"{edition.pdf_file}: embedded font misses {len(missing)} characters: {''.join(missing[:40])}"
|
||
)
|
||
|
||
styles = style_sheet(edition)
|
||
story = [NextPageTemplate("body"), PageBreak()]
|
||
story.extend(
|
||
[
|
||
Spacer(1, 34 * mm),
|
||
Paragraph(edition.title, styles["title"]),
|
||
Paragraph(edition.subtitle, styles["subtitle"]),
|
||
Paragraph(edition.author, styles["center"]),
|
||
Spacer(1, 16 * mm),
|
||
Paragraph(edition.description, styles["center"]),
|
||
Spacer(1, 15 * mm),
|
||
Paragraph("https://byoungd.github.io/up/", styles["center"]),
|
||
Paragraph(f"CC BY-NC 4.0 · {publication_date}", styles["center"]),
|
||
PageBreak(),
|
||
Paragraph(edition.contents, styles["title"]),
|
||
]
|
||
)
|
||
toc = TableOfContents()
|
||
toc.levelStyles = [
|
||
ParagraphStyle("TOC0", fontName=edition.bold_font, fontSize=9.5, leading=14, leftIndent=0, firstLineIndent=0, spaceBefore=4),
|
||
ParagraphStyle("TOC1", fontName=edition.body_font, fontSize=8.2, leading=12, leftIndent=12, firstLineIndent=0),
|
||
ParagraphStyle("TOC2", fontName=edition.body_font, fontSize=7.6, leading=11, leftIndent=24, firstLineIndent=0),
|
||
ParagraphStyle("TOC3", fontName=edition.body_font, fontSize=7.2, leading=10, leftIndent=36, firstLineIndent=0),
|
||
]
|
||
story.extend([toc, PageBreak()])
|
||
|
||
level_state = {"headings": 0}
|
||
for index, file in enumerate(chapter_files):
|
||
if index:
|
||
story.append(PageBreak())
|
||
root = parsed[file]
|
||
main = root.find(".//x:main", NS)
|
||
if main is None:
|
||
main = root.find(".//x:body", NS)
|
||
if main is None:
|
||
raise ValueError(f"{edition.pdf_file}: missing main element in {file}")
|
||
story.extend(convert_children(main, extracted_root, file, styles, edition, h1_anchors, level_state))
|
||
|
||
if level_state["headings"] < 100:
|
||
raise ValueError(f"{edition.pdf_file}: suspicious heading count {level_state['headings']}")
|
||
|
||
doc = BookDocTemplate(str(target), edition, edition.cover)
|
||
metadata = {"title": edition.title, "author": edition.author, "subject": edition.description}
|
||
doc.multiBuild(
|
||
story,
|
||
canvasmaker=lambda *args, **kwargs: InvariantCanvas(*args, metadata=metadata, **kwargs),
|
||
)
|
||
return inspect_pdf(edition, target, sha256_bytes(epub_path.read_bytes()), len(chapter_files))
|
||
|
||
|
||
def manifest_for(outputs: list[dict]) -> dict:
|
||
return {
|
||
"version": 2,
|
||
"format": "PDF 1.7",
|
||
"pageSize": "6 × 9.6 in",
|
||
"scope": "Main manuscript, glossary, and toolkit; archive and word lists remain online-only.",
|
||
"inspector": {"name": "pypdf", "version": PYPDF_VERSION},
|
||
"outputs": {metadata["language"]: metadata for metadata in outputs},
|
||
}
|
||
|
||
|
||
def main() -> None:
|
||
if not (CHECK_ONLY or CHECK_EXACT):
|
||
PUBLIC_OUTPUT.mkdir(parents=True, exist_ok=True)
|
||
LOCAL_OUTPUT.mkdir(parents=True, exist_ok=True)
|
||
|
||
with tempfile.TemporaryDirectory(prefix="life-level-up-pdf-") as temp_dir:
|
||
temp_root = Path(temp_dir)
|
||
outputs = []
|
||
for edition in EDITIONS:
|
||
target = temp_root / edition.pdf_file
|
||
metadata = build_pdf(edition, target, temp_root)
|
||
outputs.append((target, metadata))
|
||
|
||
if CHECK_ONLY or CHECK_EXACT:
|
||
committed_outputs = []
|
||
for target, metadata in outputs:
|
||
committed = PUBLIC_OUTPUT / metadata["file"]
|
||
if not committed.exists():
|
||
raise ValueError(f"{committed.relative_to(ROOT)} 不存在;运行 npm run book:pdf:build")
|
||
edition = next(value for value in EDITIONS if value.language == metadata["language"])
|
||
committed_metadata = inspect_pdf(
|
||
edition,
|
||
committed,
|
||
metadata["sourceEpubSha256"],
|
||
metadata["chapters"],
|
||
)
|
||
committed_outputs.append(committed_metadata)
|
||
if metadata["semanticSha256"] != committed_metadata["semanticSha256"]:
|
||
raise ValueError(
|
||
f"{committed.relative_to(ROOT)} 的分页、文本、书签、链接、图片或字体未与书稿同步;"
|
||
"运行 npm run book:pdf:build"
|
||
)
|
||
if CHECK_EXACT and committed.read_bytes() != target.read_bytes():
|
||
raise ValueError(
|
||
f"{committed.relative_to(ROOT)} 在当前平台未逐字节复现;运行 npm run book:pdf:build"
|
||
)
|
||
committed_manifest = PUBLIC_OUTPUT / "pdf-manifest.json"
|
||
expected_manifest_text = json.dumps(manifest_for(committed_outputs), ensure_ascii=False, indent=2) + "\n"
|
||
if not committed_manifest.exists() or committed_manifest.read_text() != expected_manifest_text:
|
||
raise ValueError(f"{committed_manifest.relative_to(ROOT)} 未与 PDF 产物同步;运行 npm run book:pdf:build")
|
||
if CHECK_EXACT:
|
||
print("PDF editions are byte-for-byte reproducible on this platform")
|
||
else:
|
||
print("PDF editions are semantically in sync; published hashes match the manifest")
|
||
return
|
||
|
||
manifest_text = json.dumps(
|
||
manifest_for([metadata for _, metadata in outputs]),
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
) + "\n"
|
||
|
||
for target, metadata in outputs:
|
||
shutil.copyfile(target, PUBLIC_OUTPUT / metadata["file"])
|
||
shutil.copyfile(target, LOCAL_OUTPUT / metadata["file"])
|
||
print(
|
||
f"updated docs/public/downloads/{metadata['file']} "
|
||
f"({metadata['pages']} pages, {metadata['bytes']} bytes)"
|
||
)
|
||
(PUBLIC_OUTPUT / "pdf-manifest.json").write_text(manifest_text)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
pdfmetrics.registerFont(TTFont("NotoSerifSC-LifeLevelUp", str(ZH_REGULAR_FONT)))
|
||
pdfmetrics.registerFont(TTFont("NotoSerifSC-LifeLevelUp-Bold", str(ZH_BOLD_FONT)))
|
||
pdfmetrics.registerFont(TTFont("NotoSans-LifeLevelUp-IPA", str(ZH_IPA_FONT)))
|
||
main()
|