193 lines
9.3 KiB
Python
193 lines
9.3 KiB
Python
"""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
|