perf(layout): cut page layout time by ~40%
Layout was dominated by work that was either repeated per word or thrown
away. Rendered output is unchanged: every page hashed byte-for-byte
identical across 3 page sizes, 2 font scales, 2 font families, and 4
alignments x 4 column widths chosen to force heavy hyphenation.
layout, 600x800, 40 pages ~200ms -> ~124ms
layout, 1404x1872, 11 pages ~168ms -> ~111ms
Measured, not guessed. The reflex fix - swapping list comprehensions for
generators - measures slower here (602ns vs 425ns for the width sum), so
those are left alone.
- Line asked its font for the advance width of a space on every
construction. FreeTypeFont.getlength(" ") costs ~18us, two orders of
magnitude more than getmetrics(), and it landed once per line. Memoise
per font object.
- Line.add_word was quadratic in the words on a line. Each candidate word
re-summed every width and rebuilt the whole per-gap spacing list, when
fitting only ever reads the first gap. Gap spacings are now a plan
materialised on demand (only render() reads the list), and widths come
from a prefix sum. The prefix list, rather than one accumulator, is what
keeps this exact: widths are floats, (total + w) - w need not give back
total, and one ulp flips an overflow decision on a line that ends flush.
- RenderingPosition.copy/__eq__/__hash__ all went through
dataclasses.asdict, a deep recursive walk, over 8 immutable scalars.
- paragraph_layouter built a Text per line purely to discard it.
- AbstractStyle.__hash__ rebuilt a 15-tuple containing 5 enums on every
dict lookup; memoised on the frozen instance (1037ns -> 160ns).
- The pyphen dictionary wrapper was rebuilt for every word that overflowed
its line, and word extraction stripped before splitting and tested each
split result for emptiness, neither of which str.split() needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,25 @@ from pyWebLayout.core import Hierarchical
|
|||||||
from pyWebLayout.style import Font
|
from pyWebLayout.style import Font
|
||||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||||
from typing import Tuple, Union, List, Optional, Dict, Any, Callable
|
from typing import Tuple, Union, List, Optional, Dict, Any, Callable
|
||||||
|
from functools import lru_cache
|
||||||
import pyphen
|
import pyphen
|
||||||
|
|
||||||
# Import LinkType for type hints (imported at module level to avoid F821 linting error)
|
# Import LinkType for type hints (imported at module level to avoid F821 linting error)
|
||||||
from pyWebLayout.abstract.functional import LinkType
|
from pyWebLayout.abstract.functional import LinkType
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def _hyphen_dict(language: Optional[str]) -> pyphen.Pyphen:
|
||||||
|
"""
|
||||||
|
The pyphen dictionary for a language, reused across words.
|
||||||
|
|
||||||
|
Pyphen caches the parsed dictionary file itself, but rebuilding the wrapper
|
||||||
|
per word still costs about 40% of a hyphenation call, and hyphenation is
|
||||||
|
attempted for every word that overflows its line.
|
||||||
|
"""
|
||||||
|
return pyphen.Pyphen(lang=language)
|
||||||
|
|
||||||
|
|
||||||
class Word:
|
class Word:
|
||||||
"""
|
"""
|
||||||
An abstract representation of a word in a document. Words can be split across
|
An abstract representation of a word in a document. Words can be split across
|
||||||
@@ -186,8 +199,7 @@ class Word:
|
|||||||
bool: True if the word was hyphenated, False otherwise.
|
bool: True if the word was hyphenated, False otherwise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
dic = pyphen.Pyphen(lang=self._style.language)
|
return list(_hyphen_dict(self._style.language).iterate(self._text))
|
||||||
return list(dic.iterate(self._text))
|
|
||||||
|
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|||||||
+169
-53
@@ -58,6 +58,15 @@ _width_cache: UsageCache = UsageCache(DEFAULT_WIDTH_CACHE_ENTRIES)
|
|||||||
_glyph_cache: SizedUsageCache = SizedUsageCache(DEFAULT_GLYPH_CACHE_BYTES, _glyph_entry_bytes)
|
_glyph_cache: SizedUsageCache = SizedUsageCache(DEFAULT_GLYPH_CACHE_BYTES, _glyph_entry_bytes)
|
||||||
_glyph_subpixel_steps: int = DEFAULT_GLYPH_SUBPIXEL_STEPS
|
_glyph_subpixel_steps: int = DEFAULT_GLYPH_SUBPIXEL_STEPS
|
||||||
|
|
||||||
|
# Every Line asks its font for the advance width of a space. That single
|
||||||
|
# FreeTypeFont.getlength(" ") call costs ~18us -- two orders of magnitude more
|
||||||
|
# than getmetrics() -- because PIL shapes the string from scratch each time, and
|
||||||
|
# it lands once per line created, which dominates the cost of laying a line out.
|
||||||
|
# There are only ever a handful of distinct fonts in play, so memoise per font
|
||||||
|
# object. Values are wrapped in a 1-tuple because None is itself a legitimate
|
||||||
|
# result (fonts that cannot report a length) and must not read as a cache miss.
|
||||||
|
_space_advance_cache: Dict[Any, Tuple[Optional[int]]] = {}
|
||||||
|
|
||||||
# Set to False the first time the fast rasterisation path is found to be
|
# Set to False the first time the fast rasterisation path is found to be
|
||||||
# unavailable (e.g. a PIL build without the private ImageDraw internals it uses),
|
# unavailable (e.g. a PIL build without the private ImageDraw internals it uses),
|
||||||
# after which every Text falls back to ImageDraw.text().
|
# after which every Text falls back to ImageDraw.text().
|
||||||
@@ -98,6 +107,35 @@ def clear_text_caches():
|
|||||||
"""Drop all cached widths and glyph bitmaps."""
|
"""Drop all cached widths and glyph bitmaps."""
|
||||||
_width_cache.clear()
|
_width_cache.clear()
|
||||||
_glyph_cache.clear()
|
_glyph_cache.clear()
|
||||||
|
_space_advance_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _space_advance(font) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
The font's own advance width for a space, in whole pixels.
|
||||||
|
|
||||||
|
None when the font cannot report one, which is the signal for callers to fall
|
||||||
|
back to their configured spacing range.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cached = _space_advance_cache.get(font)
|
||||||
|
except TypeError:
|
||||||
|
# Unhashable font object; measure without caching.
|
||||||
|
cached = None
|
||||||
|
else:
|
||||||
|
if cached is not None:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = int(round(font.getlength(" ")))
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
value = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
_space_advance_cache[font] = (value,)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def text_cache_stats() -> Dict[str, Any]:
|
def text_cache_stats() -> Dict[str, Any]:
|
||||||
@@ -215,7 +253,8 @@ class AlignmentHandler(ABC):
|
|||||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
available_width: int, min_spacing: int,
|
||||||
max_spacing: int,
|
max_spacing: int,
|
||||||
natural_spacing: Optional[int] = None
|
natural_spacing: Optional[int] = None,
|
||||||
|
total_width: Optional[float] = None
|
||||||
) -> Tuple[int, int, bool]:
|
) -> Tuple[int, int, bool]:
|
||||||
"""
|
"""
|
||||||
Calculate the spacing between words and starting position for the line.
|
Calculate the spacing between words and starting position for the line.
|
||||||
@@ -228,6 +267,11 @@ class AlignmentHandler(ABC):
|
|||||||
natural_spacing: The font's own space width. Ragged alignments use it
|
natural_spacing: The font's own space width. Ragged alignments use it
|
||||||
as a constant gap; justification ignores it. Defaults to
|
as a constant gap; justification ignores it. Defaults to
|
||||||
min_spacing when not supplied.
|
min_spacing when not supplied.
|
||||||
|
total_width: The summed width of `text_objects`, when the caller
|
||||||
|
already knows it. Purely an optimisation: a line asks its handler
|
||||||
|
to re-measure once per candidate word, and summing the whole line
|
||||||
|
each time makes filling a line quadratic in its word count. Omit
|
||||||
|
it and the sum is taken here as before.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (spacing_between_words, starting_x_position, overflow)
|
Tuple of (spacing_between_words, starting_x_position, overflow)
|
||||||
@@ -242,7 +286,8 @@ class LeftAlignmentHandler(AlignmentHandler):
|
|||||||
available_width: int,
|
available_width: int,
|
||||||
min_spacing: int,
|
min_spacing: int,
|
||||||
max_spacing: int,
|
max_spacing: int,
|
||||||
natural_spacing: Optional[int] = None
|
natural_spacing: Optional[int] = None,
|
||||||
|
total_width: Optional[float] = None
|
||||||
) -> Tuple[int, int, bool]:
|
) -> Tuple[int, int, bool]:
|
||||||
"""
|
"""
|
||||||
Calculate spacing and position for left-aligned text objects.
|
Calculate spacing and position for left-aligned text objects.
|
||||||
@@ -269,7 +314,8 @@ class LeftAlignmentHandler(AlignmentHandler):
|
|||||||
spacing = min_spacing if natural_spacing is None else natural_spacing
|
spacing = min_spacing if natural_spacing is None else natural_spacing
|
||||||
spacing = max(min_spacing, min(max_spacing, int(spacing)))
|
spacing = max(min_spacing, min(max_spacing, int(spacing)))
|
||||||
|
|
||||||
text_length = sum([text.width for text in text_objects])
|
text_length = (sum([text.width for text in text_objects])
|
||||||
|
if total_width is None else total_width)
|
||||||
num_gaps = len(text_objects) - 1
|
num_gaps = len(text_objects) - 1
|
||||||
|
|
||||||
# The spacing is constant whether or not the content fits: tightening a
|
# The spacing is constant whether or not the content fits: tightening a
|
||||||
@@ -290,7 +336,8 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
|||||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
available_width: int, min_spacing: int,
|
||||||
max_spacing: int,
|
max_spacing: int,
|
||||||
natural_spacing: Optional[int] = None
|
natural_spacing: Optional[int] = None,
|
||||||
|
total_width: Optional[float] = None
|
||||||
) -> Tuple[int, int, bool]:
|
) -> Tuple[int, int, bool]:
|
||||||
"""
|
"""
|
||||||
Centre/right alignment: constant word space, line shifted as a block.
|
Centre/right alignment: constant word space, line shifted as a block.
|
||||||
@@ -300,7 +347,8 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
|||||||
the same spacing that will actually be used, so the line lands where it
|
the same spacing that will actually be used, so the line lands where it
|
||||||
was measured to land.
|
was measured to land.
|
||||||
"""
|
"""
|
||||||
word_length = sum([word.width for word in text_objects])
|
word_length = (sum([word.width for word in text_objects])
|
||||||
|
if total_width is None else total_width)
|
||||||
|
|
||||||
# Handle single word case
|
# Handle single word case
|
||||||
if len(text_objects) <= 1:
|
if len(text_objects) <= 1:
|
||||||
@@ -329,13 +377,42 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
|||||||
"""Handler for justified text with full justification."""
|
"""Handler for justified text with full justification."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Store variable spacing for each gap to distribute remainder pixels
|
# The per-gap spacings are described by a plan rather than stored outright,
|
||||||
self._gap_spacings: List[int] = []
|
# and materialised on demand by the _gap_spacings property below. Fitting a
|
||||||
|
# line calls this handler once per candidate word and only ever looks at the
|
||||||
|
# first gap; building the whole list on each of those probes made adding n
|
||||||
|
# words to a line O(n^2). Only render() reads the full list.
|
||||||
|
self._gap_uniform: Optional[int] = None
|
||||||
|
self._gap_residual: int = 0
|
||||||
|
self._gap_count: int = 0
|
||||||
|
self._gap_cache: Optional[List[int]] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _gap_spacings(self) -> List[int]:
|
||||||
|
"""The spacing to apply at each gap, left to right."""
|
||||||
|
if self._gap_cache is None:
|
||||||
|
if self._gap_uniform is not None:
|
||||||
|
self._gap_cache = [self._gap_uniform] * self._gap_count
|
||||||
|
else:
|
||||||
|
self._gap_cache = self._distribute(self._gap_residual, self._gap_count)
|
||||||
|
return self._gap_cache
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _distribute(total: int, num_gaps: int) -> List[int]:
|
||||||
|
"""Split `total` pixels across `num_gaps` gaps by cumulative rounding."""
|
||||||
|
gaps = []
|
||||||
|
placed = 0
|
||||||
|
for i in range(1, num_gaps + 1):
|
||||||
|
cumulative = int(round(total * i / num_gaps))
|
||||||
|
gaps.append(cumulative - placed)
|
||||||
|
placed = cumulative
|
||||||
|
return gaps
|
||||||
|
|
||||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
available_width: int, min_spacing: int,
|
||||||
max_spacing: int,
|
max_spacing: int,
|
||||||
natural_spacing: Optional[int] = None
|
natural_spacing: Optional[int] = None,
|
||||||
|
total_width: Optional[float] = None
|
||||||
) -> Tuple[int, int, bool]:
|
) -> Tuple[int, int, bool]:
|
||||||
"""
|
"""
|
||||||
Justified alignment distributes space to fill the entire line width.
|
Justified alignment distributes space to fill the entire line width.
|
||||||
@@ -347,14 +424,17 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
|||||||
is min_spacing to ensure readability.
|
is min_spacing to ensure readability.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
word_length = sum([word.width for word in text_objects])
|
word_length = (sum([word.width for word in text_objects])
|
||||||
|
if total_width is None else total_width)
|
||||||
residual_space = available_width - word_length
|
residual_space = available_width - word_length
|
||||||
num_gaps = max(1, len(text_objects) - 1)
|
num_gaps = max(1, len(text_objects) - 1)
|
||||||
|
|
||||||
# Check if we have enough space for minimum spacing
|
# Check if we have enough space for minimum spacing
|
||||||
if residual_space // num_gaps < min_spacing:
|
if residual_space // num_gaps < min_spacing:
|
||||||
# Not enough space - this is overflow
|
# Not enough space - this is overflow
|
||||||
self._gap_spacings = [min_spacing] * num_gaps
|
self._gap_uniform = min_spacing
|
||||||
|
self._gap_count = num_gaps
|
||||||
|
self._gap_cache = None
|
||||||
return min_spacing, 0, True
|
return min_spacing, 0, True
|
||||||
|
|
||||||
# Distribute the residual by cumulative rounding rather than by taking a
|
# Distribute the residual by cumulative rounding rather than by taking a
|
||||||
@@ -365,14 +445,14 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
|||||||
# ragged right edge on otherwise justified text. Rounding the running
|
# ragged right edge on otherwise justified text. Rounding the running
|
||||||
# total makes the gaps sum to the residual exactly.
|
# total makes the gaps sum to the residual exactly.
|
||||||
total = int(round(residual_space))
|
total = int(round(residual_space))
|
||||||
self._gap_spacings = []
|
self._gap_uniform = None
|
||||||
placed = 0
|
self._gap_residual = total
|
||||||
for i in range(1, num_gaps + 1):
|
self._gap_count = num_gaps
|
||||||
cumulative = int(round(total * i / num_gaps))
|
self._gap_cache = None
|
||||||
self._gap_spacings.append(cumulative - placed)
|
|
||||||
placed = cumulative
|
|
||||||
|
|
||||||
return self._gap_spacings[0], 0, False
|
# The first gap is the whole of the plan that fitting needs, and it falls
|
||||||
|
# out of the same cumulative rounding as _distribute would give it.
|
||||||
|
return int(round(total / num_gaps)), 0, False
|
||||||
|
|
||||||
|
|
||||||
class Text(Renderable, Queriable):
|
class Text(Renderable, Queriable):
|
||||||
@@ -689,6 +769,9 @@ class Line(Box):
|
|||||||
"""
|
"""
|
||||||
super().__init__(origin, size, callback, sheet, mode, halign, valign)
|
super().__init__(origin, size, callback, sheet, mode, halign, valign)
|
||||||
self._text_objects: List['Text'] = [] # Store Text objects directly
|
self._text_objects: List['Text'] = [] # Store Text objects directly
|
||||||
|
# Prefix sums of the widths in _text_objects, kept in step by _push_text /
|
||||||
|
# _pop_text. Element 0 is the empty sum. See _push_text for the rationale.
|
||||||
|
self._width_prefix: List[float] = [0.0]
|
||||||
self._spacing = spacing # (min_spacing, max_spacing)
|
self._spacing = spacing # (min_spacing, max_spacing)
|
||||||
self._font = font if font else Font() # Use default font if none provided
|
self._font = font if font else Font() # Use default font if none provided
|
||||||
self._current_width = 0 # Track the current width used
|
self._current_width = 0 # Track the current width used
|
||||||
@@ -704,10 +787,7 @@ class Line(Box):
|
|||||||
|
|
||||||
# The font's own space advance. Ragged alignments use this as their
|
# The font's own space advance. Ragged alignments use this as their
|
||||||
# constant word gap rather than stretching to fill the measure.
|
# constant word gap rather than stretching to fill the measure.
|
||||||
try:
|
self._natural_spacing = _space_advance(self._font.font)
|
||||||
self._natural_spacing = int(round(self._font.font.getlength(" ")))
|
|
||||||
except (AttributeError, TypeError, ValueError):
|
|
||||||
self._natural_spacing = None
|
|
||||||
|
|
||||||
# Hyphenation configuration parameters
|
# Hyphenation configuration parameters
|
||||||
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
||||||
@@ -771,6 +851,45 @@ class Line(Box):
|
|||||||
"""Set the next line in sequence"""
|
"""Set the next line in sequence"""
|
||||||
self._next = line
|
self._next = line
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _content_width(self) -> float:
|
||||||
|
"""Summed width of the line's current contents."""
|
||||||
|
return self._width_prefix[-1]
|
||||||
|
|
||||||
|
def _push_text(self, text: 'Text'):
|
||||||
|
"""
|
||||||
|
Append a Text to the line, keeping the running width sum in step.
|
||||||
|
|
||||||
|
Fitting a word is a trial: the candidate is pushed, measured, and popped
|
||||||
|
again if it did not fit, so the line's contents churn far more often than
|
||||||
|
they grow. Tracking the sum here rather than re-adding every width on each
|
||||||
|
measurement is what keeps filling a line linear in its word count.
|
||||||
|
|
||||||
|
The sum is kept as a prefix list rather than as one accumulator that is
|
||||||
|
added to and subtracted from. Widths are floats, so `(total + w) - w` need
|
||||||
|
not give back `total` exactly, and a drift of one ulp is enough to flip an
|
||||||
|
overflow decision on a line that ends flush. Truncating a prefix list
|
||||||
|
restores the earlier total bit for bit, and each entry is built by the same
|
||||||
|
left-to-right addition sum() would perform.
|
||||||
|
"""
|
||||||
|
self._text_objects.append(text)
|
||||||
|
self._width_prefix.append(self._width_prefix[-1] + text.width)
|
||||||
|
|
||||||
|
def _pop_text(self) -> 'Text':
|
||||||
|
"""Remove the last Text from the line, keeping the width sum in step."""
|
||||||
|
text = self._text_objects.pop()
|
||||||
|
self._width_prefix.pop()
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _measure(self, handler: Optional[AlignmentHandler] = None
|
||||||
|
) -> Tuple[int, int, bool]:
|
||||||
|
"""Ask an alignment handler to place the line's current contents."""
|
||||||
|
if handler is None:
|
||||||
|
handler = self._alignment_handler
|
||||||
|
return handler.calculate_spacing_and_position(
|
||||||
|
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
||||||
|
self._natural_spacing, self._content_width)
|
||||||
|
|
||||||
def add_word(self,
|
def add_word(self,
|
||||||
word: 'Word',
|
word: 'Word',
|
||||||
part: Optional[Text] = None) -> Tuple[bool,
|
part: Optional[Text] = None) -> Tuple[bool,
|
||||||
@@ -789,7 +908,7 @@ class Line(Box):
|
|||||||
"""
|
"""
|
||||||
# First, add any pretext from previous hyphenation
|
# First, add any pretext from previous hyphenation
|
||||||
if part is not None:
|
if part is not None:
|
||||||
self._text_objects.append(part)
|
self._push_text(part)
|
||||||
self._words.append(word)
|
self._words.append(word)
|
||||||
part.add_line(self)
|
part.add_line(self)
|
||||||
|
|
||||||
@@ -818,10 +937,8 @@ class Line(Box):
|
|||||||
line=self)
|
line=self)
|
||||||
else:
|
else:
|
||||||
text = Text.from_word(word, self._draw)
|
text = Text.from_word(word, self._draw)
|
||||||
self._text_objects.append(text)
|
self._push_text(text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
spacing, position, overflow = self._measure()
|
||||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
|
||||||
self._natural_spacing)
|
|
||||||
|
|
||||||
if not overflow:
|
if not overflow:
|
||||||
# Word fits! Add it completely
|
# Word fits! Add it completely
|
||||||
@@ -833,7 +950,7 @@ class Line(Box):
|
|||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
# Word doesn't fit, remove it and try hyphenation
|
# Word doesn't fit, remove it and try hyphenation
|
||||||
_ = self._text_objects.pop()
|
self._pop_text()
|
||||||
|
|
||||||
# Step 1: Try pyphen hyphenation
|
# Step 1: Try pyphen hyphenation
|
||||||
pyphen_splits = word.possible_hyphenation()
|
pyphen_splits = word.possible_hyphenation()
|
||||||
@@ -866,11 +983,9 @@ class Line(Box):
|
|||||||
source=word)
|
source=word)
|
||||||
|
|
||||||
# Check if first part fits
|
# Check if first part fits
|
||||||
self._text_objects.append(first_text)
|
self._push_text(first_text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
spacing, position, overflow = self._measure()
|
||||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
self._pop_text()
|
||||||
self._natural_spacing)
|
|
||||||
_ = self._text_objects.pop()
|
|
||||||
|
|
||||||
if not overflow:
|
if not overflow:
|
||||||
# This split fits! Add it to valid options
|
# This split fits! Add it to valid options
|
||||||
@@ -883,7 +998,7 @@ class Line(Box):
|
|||||||
first_text, second_text, spacing, position = best_split
|
first_text, second_text, spacing, position = best_split
|
||||||
|
|
||||||
# Apply the split
|
# Apply the split
|
||||||
self._text_objects.append(first_text)
|
self._push_text(first_text)
|
||||||
first_text.line = self
|
first_text.line = self
|
||||||
word.add_concete((first_text, second_text))
|
word.add_concete((first_text, second_text))
|
||||||
self._spacing_render = spacing
|
self._spacing_render = spacing
|
||||||
@@ -894,7 +1009,7 @@ class Line(Box):
|
|||||||
# Step 3: Try brute force hyphenation (only for long words)
|
# Step 3: Try brute force hyphenation (only for long words)
|
||||||
if len(word.text) >= self._min_word_length_for_brute_force:
|
if len(word.text) >= self._min_word_length_for_brute_force:
|
||||||
# Calculate available space for the word
|
# Calculate available space for the word
|
||||||
word_length = sum([text.width for text in self._text_objects])
|
word_length = self._content_width
|
||||||
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
|
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
|
||||||
remaining = self._size[0] - word_length - spacing_length
|
remaining = self._size[0] - word_length - spacing_length
|
||||||
|
|
||||||
@@ -938,10 +1053,8 @@ class Line(Box):
|
|||||||
source=word)
|
source=word)
|
||||||
|
|
||||||
# Verify the first part actually fits
|
# Verify the first part actually fits
|
||||||
self._text_objects.append(first_text)
|
self._push_text(first_text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
spacing, position, overflow = self._measure()
|
||||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
|
||||||
self._natural_spacing)
|
|
||||||
|
|
||||||
if not overflow:
|
if not overflow:
|
||||||
# Brute force split works!
|
# Brute force split works!
|
||||||
@@ -954,7 +1067,7 @@ class Line(Box):
|
|||||||
return True, second_text
|
return True, second_text
|
||||||
else:
|
else:
|
||||||
# Doesn't fit, remove it
|
# Doesn't fit, remove it
|
||||||
_ = self._text_objects.pop()
|
self._pop_text()
|
||||||
|
|
||||||
# Step 4: Word cannot be hyphenated or split, move to next line
|
# Step 4: Word cannot be hyphenated or split, move to next line
|
||||||
return False, None
|
return False, None
|
||||||
@@ -972,9 +1085,7 @@ class Line(Box):
|
|||||||
# justified paragraph.
|
# justified paragraph.
|
||||||
handler = self.render_alignment_handler
|
handler = self.render_alignment_handler
|
||||||
if len(self._text_objects) > 0:
|
if len(self._text_objects) > 0:
|
||||||
spacing, position, overflow = handler.calculate_spacing_and_position(
|
spacing, position, overflow = self._measure(handler)
|
||||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
|
||||||
self._natural_spacing)
|
|
||||||
self._spacing_render = spacing
|
self._spacing_render = spacing
|
||||||
self._position_render = position
|
self._position_render = position
|
||||||
|
|
||||||
@@ -982,28 +1093,33 @@ class Line(Box):
|
|||||||
|
|
||||||
# Start x_cursor at line origin plus any alignment offset
|
# Start x_cursor at line origin plus any alignment offset
|
||||||
x_cursor = self._origin[0] + self._position_render
|
x_cursor = self._origin[0] + self._position_render
|
||||||
for i, text in enumerate(self._text_objects):
|
|
||||||
|
# Everything the loop needs that does not vary per word is resolved once.
|
||||||
|
# Only justified lines carry per-gap spacings; every other alignment uses
|
||||||
|
# the single spacing figured above.
|
||||||
|
texts = self._text_objects
|
||||||
|
last = len(texts) - 1
|
||||||
|
draw = self._draw
|
||||||
|
default_spacing = self._spacing_render
|
||||||
|
gaps = handler._gap_spacings if isinstance(handler, JustifyAlignmentHandler) else ()
|
||||||
|
gap_count = len(gaps)
|
||||||
|
|
||||||
|
for i, text in enumerate(texts):
|
||||||
# Update text draw context to current draw context
|
# Update text draw context to current draw context
|
||||||
text._draw = self._draw
|
text._draw = draw
|
||||||
text.set_origin(np.array([x_cursor, y_cursor]))
|
text.set_origin(np.array([x_cursor, y_cursor]))
|
||||||
|
|
||||||
# Determine next text object for continuous decoration
|
# Determine next text object for continuous decoration
|
||||||
next_text = self._text_objects[i + 1] if i + \
|
next_text = texts[i + 1] if i < last else None
|
||||||
1 < len(self._text_objects) else None
|
|
||||||
|
|
||||||
# Get the spacing for this specific gap (variable for justified text)
|
# Get the spacing for this specific gap (variable for justified text)
|
||||||
if isinstance(handler, JustifyAlignmentHandler) and \
|
current_spacing = gaps[i] if i < gap_count else default_spacing
|
||||||
hasattr(handler, '_gap_spacings') and \
|
|
||||||
i < len(handler._gap_spacings):
|
|
||||||
current_spacing = handler._gap_spacings[i]
|
|
||||||
else:
|
|
||||||
current_spacing = self._spacing_render
|
|
||||||
|
|
||||||
# Render with next text information for continuous underline/strikethrough
|
# Render with next text information for continuous underline/strikethrough
|
||||||
text.render(next_text, current_spacing)
|
text.render(next_text, current_spacing)
|
||||||
# Add text width, then spacing only if there are more words
|
# Add text width, then spacing only if there are more words
|
||||||
x_cursor += text.width
|
x_cursor += text.width
|
||||||
if i < len(self._text_objects) - 1:
|
if i < last:
|
||||||
x_cursor += current_spacing
|
x_cursor += current_spacing
|
||||||
|
|
||||||
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
|
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
|
||||||
|
|||||||
@@ -403,13 +403,13 @@ def extract_words_from_nodes(nodes: List, context: StyleContext) -> List[Word]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(child, NavigableString):
|
if isinstance(child, NavigableString):
|
||||||
# Plain text - split into words
|
# Plain text - split into words. Argument-less str.split() already
|
||||||
text = str(child).strip()
|
# discards surrounding whitespace and never yields an empty string, so
|
||||||
if text:
|
# it needs neither a preceding strip() nor a per-word emptiness test.
|
||||||
word_texts = text.split()
|
font = context.font
|
||||||
for word_text in word_texts:
|
background = context.background
|
||||||
if word_text:
|
words.extend([Word(word_text, font, background)
|
||||||
words.append(Word(word_text, context.font, context.background))
|
for word_text in str(child).split()])
|
||||||
elif isinstance(child, Tag):
|
elif isinstance(child, Tag):
|
||||||
# Special handling for <a> tags (hyperlinks)
|
# Special handling for <a> tags (hyperlinks)
|
||||||
if child.name.lower() == "a":
|
if child.name.lower() == "a":
|
||||||
|
|||||||
@@ -163,12 +163,9 @@ def paragraph_layouter(paragraph: Paragraph,
|
|||||||
y_cursor = page._current_y_offset
|
y_cursor = page._current_y_offset
|
||||||
x_cursor = page.content_origin[0]
|
x_cursor = page.content_origin[0]
|
||||||
|
|
||||||
# Create a temporary Text object to calculate word width
|
# `word` is accepted for call-site readability only: the line that is about
|
||||||
if word:
|
# to be created measures it when it is added, so measuring it here as well
|
||||||
temp_text = Text.from_word(word, page.measurement_draw)
|
# only paid for a Text object that was immediately discarded.
|
||||||
temp_text.width
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return Line(
|
return Line(
|
||||||
spacing=word_spacing_constraints,
|
spacing=word_spacing_constraints,
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ class RenderingPosition:
|
|||||||
remaining_pretext: Optional[str] = None # Hyphenated word continuation
|
remaining_pretext: Optional[str] = None # Hyphenated word continuation
|
||||||
page_y_offset: int = 0 # Vertical position on page
|
page_y_offset: int = 0 # Vertical position on page
|
||||||
|
|
||||||
|
def _key(self) -> Tuple[Any, ...]:
|
||||||
|
"""
|
||||||
|
The fields in declaration order.
|
||||||
|
|
||||||
|
Copying, comparing and hashing a position all used to go through
|
||||||
|
dataclasses.asdict, which walks the field list and deep-copies each value.
|
||||||
|
Every field here is an immutable scalar, so that traversal bought nothing
|
||||||
|
and these three run constantly during page navigation and buffer lookups.
|
||||||
|
"""
|
||||||
|
return (self.chapter_index, self.block_index, self.word_index,
|
||||||
|
self.table_row, self.table_col, self.list_item_index,
|
||||||
|
self.remaining_pretext, self.page_y_offset)
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Serialize position for saving to file/database"""
|
"""Serialize position for saving to file/database"""
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
@@ -54,17 +67,17 @@ class RenderingPosition:
|
|||||||
|
|
||||||
def copy(self) -> 'RenderingPosition':
|
def copy(self) -> 'RenderingPosition':
|
||||||
"""Create a copy of this position"""
|
"""Create a copy of this position"""
|
||||||
return RenderingPosition(**asdict(self))
|
return RenderingPosition(*self._key())
|
||||||
|
|
||||||
def __eq__(self, other) -> bool:
|
def __eq__(self, other) -> bool:
|
||||||
"""Check if two positions are equal"""
|
"""Check if two positions are equal"""
|
||||||
if not isinstance(other, RenderingPosition):
|
if not isinstance(other, RenderingPosition):
|
||||||
return False
|
return False
|
||||||
return asdict(self) == asdict(other)
|
return self._key() == other._key()
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
"""Make position hashable for use as dict key"""
|
"""Make position hashable for use as dict key"""
|
||||||
return hash(tuple(asdict(self).values()))
|
return hash(self._key())
|
||||||
|
|
||||||
|
|
||||||
class ChapterInfo:
|
class ChapterInfo:
|
||||||
|
|||||||
@@ -112,7 +112,17 @@ class AbstractStyle:
|
|||||||
Since this is a frozen dataclass, it should be hashable by default,
|
Since this is a frozen dataclass, it should be hashable by default,
|
||||||
but we provide a custom implementation to ensure all fields are
|
but we provide a custom implementation to ensure all fields are
|
||||||
properly considered and to handle the Union types correctly.
|
properly considered and to handle the Union types correctly.
|
||||||
|
|
||||||
|
The result is memoised on first use. Styles are used as dictionary keys
|
||||||
|
throughout parsing and style resolution, and five of the fields are enum
|
||||||
|
members whose own __hash__ is a Python-level call, so rebuilding the
|
||||||
|
15-tuple on every lookup was a measurable share of document parsing. The
|
||||||
|
class is frozen, so the value cannot go stale.
|
||||||
"""
|
"""
|
||||||
|
cached = self.__dict__.get('_hash_cache')
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
# Convert all values to hashable forms
|
# Convert all values to hashable forms
|
||||||
hashable_values = (
|
hashable_values = (
|
||||||
self.font_family,
|
self.font_family,
|
||||||
@@ -132,7 +142,9 @@ class AbstractStyle:
|
|||||||
self.parent_style_id
|
self.parent_style_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return hash(hashable_values)
|
result = hash(hashable_values)
|
||||||
|
object.__setattr__(self, '_hash_cache', result)
|
||||||
|
return result
|
||||||
|
|
||||||
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
|
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user