"""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