Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math

This commit is contained in:
2026-10-13 17:16:50 +08:00
parent c4efbed9b0
commit d671e412ae
26 changed files with 1210 additions and 78 deletions
+192
View File
@@ -0,0 +1,192 @@
"""Conservative line breaking for a single ordinary horizontal paragraph.
Supports CJK with basic Latin words, common ASCII number/date/version tokens,
paired straight quotes and common Chinese punctuation. It preserves every input
character, including spaces. This is not a full Unicode line-break/grapheme,
vertical composition, punctuation-compression or hanging-punctuation engine.
Short labels and table values are not paragraphs and should not use this helper.
The caller supplies deterministic, nonnegative finite text advance measurements
in the same units as max_width. No font library is imported. Width monotonicity
is not assumed. A boundary DAG is solved once, in polynomial time; measurements
of repeated substrings are cached within this call with bounded cache storage.
Common punctuation rules follow W3C CLREQ 6.1.1 and 6.1.2. The additional ban on
one-content-character lines applies to EVERY line, beyond CLREQ 7.1.2's usual
last-line orphan treatment, as required by this workflow.
"""
from __future__ import annotations
from functools import lru_cache
import math
import re
from typing import Callable
import unicodedata
_OPEN = frozenset("([{([{〈《「『【〔〖〘〚‘“«‹﹁﹃﹙﹛﹝")
_CLOSE = frozenset(")]})]}〉》」』】〕〗〙〛’”»›﹂﹄﹚﹜﹞")
_PAUSE = frozenset("、。,.,.;:!?;:?!。、·・…—%%‰")
_SENTENCE = frozenset("。.!!??")
_CLAUSE = frozenset(",,;;::、")
# Western words include ordinary contractions, hyphenated identifiers and
# versions; numbers include grouping, decimals, dates/times, exponents and units.
_TOKEN = re.compile(
r"[A-Za-z][A-Za-z0-9_]*(?:['’._/\-][A-Za-z0-9_]+)*"
r"|[+\-±$¥¥€£]?(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)"
r"(?:\.[0-9]+)*(?:[/:\-][0-9]+)*(?:[eE][+\-]?[0-9]+)?"
r"(?:[%%‰℃℉°₫]|[A-Za-z]+)?"
r"|……|——"
)
def _paragraph_input(text: str) -> None:
if not isinstance(text, str) or not text:
raise ValueError("text must be a nonempty semantic paragraph")
if any(c in "\r\n\u2028\u2029" or unicodedata.category(c) in ("Cc", "Cf") for c in text):
raise ValueError("paragraph must not contain line breaks, control or format characters")
def _content(char: str) -> bool:
return not char.isspace() and unicodedata.category(char)[0] not in "PM"
def _boundary_data(text: str):
"""Cache character classes and legal token boundaries for the whole text."""
count = len(text)
boundaries = [True] * (count + 1)
for match in _TOKEN.finditer(text):
for i in range(match.start() + 1, match.end()):
boundaries[i] = False
opens, closes = [], []
straight_open = {"'": False, '"': False}
content = [0]
for i, char in enumerate(text):
category = unicodedata.category(char)
opening = char in _OPEN or category in ("Ps", "Pi")
closing = char in _CLOSE or char in _PAUSE or category in ("Pe", "Pf")
if char in straight_open:
# An apostrophe within a protected Western word is not a quote.
if char != "'" or boundaries[i] or boundaries[i + 1]:
opening = not straight_open[char]
closing = straight_open[char]
straight_open[char] = not straight_open[char]
opens.append(opening)
closes.append(closing)
content.append(content[-1] + int(_content(char)))
if category.startswith("M"):
boundaries[i] = False
first, last = [count] * (count + 1), [-1] * (count + 1)
for i in range(count - 1, -1, -1):
first[i] = first[i + 1] if text[i].isspace() else i
for end in range(1, count + 1):
last[end] = last[end - 1] if text[end - 1].isspace() else end - 1
return boundaries, opens, closes, content, first, last
def validate_lines(lines: list[str]) -> list[dict]:
"""Return line-numbered rule violations; widths need the caller's measure.
The full concatenated paragraph supplies quote/token context. No content or
whitespace is changed. An empty list reports an empty paragraph; malformed
API arguments raise ValueError rather than returning a misleading success.
"""
if not isinstance(lines, list) or any(not isinstance(line, str) for line in lines):
raise ValueError("lines must be a list of strings")
if not lines:
return [{"line": 1, "code": "empty_paragraph", "message": "paragraph has no lines"}]
text = "".join(lines)
boundaries, opens, closes, content, first, last = _boundary_data(text)
issues, start = [], 0
for number, line in enumerate(lines, 1):
end = start + len(line)
def issue(code, message):
issues.append({"line": number, "code": code, "message": message})
if not line or line.isspace():
issue("empty_line", "paragraph line must contain text")
else:
if any(c in "\r\n\u2028\u2029" or unicodedata.category(c) in ("Cc", "Cf") for c in line):
issue("unsupported_control", "line contains a line break, control or format character")
if closes[first[start]]:
issue("prohibited_line_start", "line starts with closing or pause punctuation")
if opens[last[end]]:
issue("prohibited_line_end", "line ends with opening punctuation")
amount = content[end] - content[start]
if amount == 0:
issue("punctuation_only", "paragraph line contains only punctuation and whitespace")
elif amount == 1:
issue("single_content_character", "paragraph line contains only one content character")
if end < len(text) and not boundaries[end]:
issue("split_token", "line break splits a protected word, number, paired mark or combining sequence")
start = end
return issues
def wrap_paragraph(text: str, max_width: float, measure: Callable[[str], float], *,
max_lines: int | None = None) -> list[str]:
"""Reflow the complete paragraph without changing content or font size.
Among valid layouts choose the fewest lines, then prefer sentence/clause
boundaries, then reduce raggedness. Dynamic programming can move earlier
breaks to avoid an orphan anywhere, rather than patching the final line.
At most O(b²) segment measurements are considered for b legal boundaries;
no exponential recursion and no assumption of additive/monotone font width.
"""
_paragraph_input(text)
if type(max_width) not in (int, float) or not math.isfinite(max_width) or max_width <= 0:
raise ValueError("max_width must be a positive finite number")
if not callable(measure):
raise ValueError("measure must be callable")
if max_lines is not None and (type(max_lines) is not int or max_lines < 1):
raise ValueError("max_lines must be a positive integer or None")
boundaries, opens, closes, content, first, last = _boundary_data(text)
if content[-1] < 2:
raise ValueError("paragraph requires at least two content characters; short labels are outside this helper")
points = [i for i, allowed in enumerate(boundaries) if allowed]
count = len(points)
@lru_cache(maxsize=4096)
def width(value):
result = measure(value)
if type(result) not in (int, float) or not math.isfinite(result) or result < 0:
raise ValueError("measure must return a nonnegative finite number")
return result
# Score line endings once; closing quotation/bracket marks can follow the
# actual sentence punctuation without losing its preferred break status.
preferences = []
for end in points:
index = last[end]
while index >= 0 and (text[index] in _CLOSE or text[index] in "\"'" and closes[index]):
index -= 1
char = text[index] if index >= 0 else ""
preferences.append(0 if char in _SENTENCE else 1 if char in _CLAUSE
else 2 if end and text[end - 1].isspace() else 3)
best, following = [None] * count, [None] * count
best[-1] = (0, 0, 0.0)
for i in range(count - 2, -1, -1):
start = points[i]
if first[start] == len(text) or closes[first[start]]:
continue
for j in range(i + 1, count):
end = points[j]
if best[j] is None or content[end] - content[start] < 2 or opens[last[end]]:
continue
measured = width(text[start:end])
if measured > max_width:
continue
tail = best[j]
score = (tail[0] + 1, tail[1] + (preferences[j] if j < count - 1 else 0),
tail[2] + ((max_width - measured) / max_width) ** 2)
if best[i] is None or score < best[i]:
best[i], following[i] = score, j
if best[0] is None:
raise ValueError("paragraph cannot fit width while preserving punctuation, tokens and at least two content characters per line; enlarge bounds, rephrase or split the paragraph")
if max_lines is not None and best[0][0] > max_lines:
raise ValueError(f"paragraph requires {best[0][0]} valid lines, exceeding max_lines={max_lines}; enlarge bounds, rephrase or split the paragraph")
lines, current = [], 0
while current < count - 1:
next_index = following[current]
lines.append(text[points[current]:points[next_index]])
current = next_index
return lines