Rework layout engine around exact-glyph collision, add tests and docs sync
Replace the old bbox/heuristic placement (scale search rounds, large-font capping, stratified sampling, fill-retry ladders) with an area-model font sizing pass feeding a C++ exact-glyph collision engine (centroid-biased spiral + random probing, HD clearance refinement, density/hole optimization). Simplify the frontend advanced-params panel and JobParams type to match the surviving config surface, add a layout-constraints test suite and a repeatable benchmark tool, and bring docs/*.md back in sync with current code (plus new TESTING.md and DEPLOYMENT.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+330
-209
@@ -1,16 +1,159 @@
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
from matplotlib.path import Path as MplPath
|
||||
from matplotlib.textpath import TextPath
|
||||
from matplotlib.transforms import Affine2D
|
||||
|
||||
from . import config
|
||||
from .ewc import EfficientWordCloud
|
||||
from .fonts import get_cached_font, get_font_properties
|
||||
|
||||
# ── Fast SVG path cache (fontTools outlines, unscaled per-char) ──────────────
|
||||
# font_path -> (glyph_set, cmap, units_per_em)
|
||||
_FT_FONT_CACHE = {}
|
||||
# (font_path, char) -> (svg_path_d_in_font_units, advance_width)
|
||||
_FT_CHAR_PATH_CACHE = {}
|
||||
# (font_path, word, size, orient) -> (path_d_scaled, tx0, ty0)
|
||||
_SVG_SHAPE_CACHE = {}
|
||||
|
||||
|
||||
def _load_ft_font(font_path):
|
||||
cached = _FT_FONT_CACHE.get(font_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
# .ttc collections: try face 0 first
|
||||
try:
|
||||
tt = TTFont(font_path, fontNumber=0)
|
||||
except TypeError:
|
||||
tt = TTFont(font_path)
|
||||
glyph_set = tt.getGlyphSet()
|
||||
cmap = tt.getBestCmap() or {}
|
||||
units = tt["head"].unitsPerEm
|
||||
cached = (tt, glyph_set, cmap, units)
|
||||
_FT_FONT_CACHE[font_path] = cached
|
||||
return cached
|
||||
|
||||
|
||||
|
||||
|
||||
def build_svg_text_path_cached(word, size, x, y, font_path, orient):
|
||||
"""Return (path_d, tx, ty, None). Geometry is cached for identical glyphs.
|
||||
|
||||
Path is in y-up font space (same as matplotlib TextPath). Caller applies
|
||||
translate(tx, ty) scale(1, -1) to place it on the canvas.
|
||||
"""
|
||||
key = (font_path, word, int(size), bool(orient))
|
||||
cached = _SVG_SHAPE_CACHE.get(key)
|
||||
if cached is None:
|
||||
try:
|
||||
cached = _build_shape_fonttools(word, size, font_path, orient)
|
||||
except Exception:
|
||||
cached = _build_shape_matplotlib(word, size, font_path, orient)
|
||||
_SVG_SHAPE_CACHE[key] = cached
|
||||
if len(_SVG_SHAPE_CACHE) > 20000:
|
||||
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
|
||||
if i % 2 == 0:
|
||||
_SVG_SHAPE_CACHE.pop(k, None)
|
||||
path_d, tx0, ty0 = cached
|
||||
return path_d, tx0 + x, ty0 + y, None
|
||||
|
||||
|
||||
def _build_shape_fonttools(word, size, font_path, orient):
|
||||
from fontTools.pens.svgPathPen import SVGPathPen
|
||||
from fontTools.pens.transformPen import TransformPen
|
||||
from fontTools.misc.transform import Transform
|
||||
|
||||
_tt, glyph_set, cmap, units = _load_ft_font(font_path)
|
||||
scale = float(size) / float(units)
|
||||
pen = SVGPathPen(glyph_set)
|
||||
cursor = 0.0
|
||||
for ch in word:
|
||||
gname = cmap.get(ord(ch))
|
||||
if not gname or gname not in glyph_set:
|
||||
continue
|
||||
glyph = glyph_set[gname]
|
||||
if orient:
|
||||
# Horizontal layout then rotate -90° around origin:
|
||||
# point (px, py) in string space -> after scale: (s*px, s*py)
|
||||
# rotate -90: (s*py, -s*px). Compose with glyph origin at cursor:
|
||||
# glyph local (gx,gy) -> (scale*gx + cursor, scale*gy)
|
||||
# -> rotate -90: (scale*gy, -(scale*gx + cursor)) = (scale*gy, -scale*gx - cursor)
|
||||
# matrix: x' = 0*gx + scale*gy + 0; y' = -scale*gx + 0*gy - cursor
|
||||
# Transform(xx, xy, yx, yy, dx, dy): x' = xx*x + xy*y + dx; y' = yx*x + yy*y + dy
|
||||
# xx=0, xy=scale, yx=-scale, yy=0, dx=0, dy=-cursor
|
||||
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, -cursor))
|
||||
else:
|
||||
tp = TransformPen(pen, Transform(scale, 0, 0, scale, cursor, 0))
|
||||
glyph.draw(tp)
|
||||
cursor += float(glyph.width) * scale
|
||||
|
||||
path_d = pen.getCommands()
|
||||
if not path_d:
|
||||
return "", 0.0, 0.0
|
||||
|
||||
xmin, ymin, xmax, ymax = _path_bbox(path_d)
|
||||
# Offsets placing glyph top-left at (0,0) under translate(tx,ty) scale(1,-1)
|
||||
tx0 = -xmin
|
||||
ty0 = ymax
|
||||
return path_d, tx0, ty0
|
||||
|
||||
|
||||
def _path_bbox(path_d):
|
||||
import re
|
||||
nums = [float(n) for n in re.findall(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)]
|
||||
# This is approximate (includes arc radii etc.) but good enough for placement offsets
|
||||
# Better: parse properly. For font outlines, commands are mostly M/L/Q/C/Z with coords.
|
||||
xs, ys = [], []
|
||||
tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
t = tokens[i]
|
||||
if t.isalpha():
|
||||
cmd = t
|
||||
i += 1
|
||||
if cmd in "Zz":
|
||||
continue
|
||||
if cmd in "Hh":
|
||||
while i < len(tokens) and not tokens[i].isalpha():
|
||||
xs.append(float(tokens[i])); i += 1
|
||||
elif cmd in "Vv":
|
||||
while i < len(tokens) and not tokens[i].isalpha():
|
||||
ys.append(float(tokens[i])); i += 1
|
||||
elif cmd in "Aa":
|
||||
while i + 6 < len(tokens) and not tokens[i].isalpha():
|
||||
xs.append(float(tokens[i + 5])); ys.append(float(tokens[i + 6])); i += 7
|
||||
else:
|
||||
while i + 1 < len(tokens) and not tokens[i].isalpha():
|
||||
xs.append(float(tokens[i])); ys.append(float(tokens[i + 1])); i += 2
|
||||
else:
|
||||
i += 1
|
||||
if not xs or not ys:
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def _build_shape_matplotlib(word, size, font_path, orient):
|
||||
from matplotlib.textpath import TextPath
|
||||
|
||||
path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size)
|
||||
if orient:
|
||||
path = path.transformed(Affine2D().rotate_deg(-90))
|
||||
bbox = path.get_extents()
|
||||
tx0 = -bbox.xmin
|
||||
ty0 = bbox.ymax
|
||||
path_d = mpl_path_to_svg_d(path)
|
||||
return path_d, tx0, ty0
|
||||
|
||||
|
||||
def build_svg_text_path(word, size, x, y, font_path, orient):
|
||||
path_d, tx, ty, _ = build_svg_text_path_cached(word, size, x, y, font_path, orient)
|
||||
return path_d, tx, ty, None
|
||||
|
||||
|
||||
def normalize_relative_scores(values):
|
||||
if not values:
|
||||
@@ -18,7 +161,10 @@ def normalize_relative_scores(values):
|
||||
v_min = min(values)
|
||||
v_max = max(values)
|
||||
if math.isclose(v_min, v_max):
|
||||
return [1.0 for _ in values]
|
||||
# Equal weights should produce a neutral, equal hierarchy. Returning
|
||||
# 1.0 made every word request the maximum size and later words were
|
||||
# arbitrarily shrunk by placement order.
|
||||
return [0.5 for _ in values]
|
||||
scale = v_max - v_min
|
||||
return [(value - v_min) / scale for value in values]
|
||||
|
||||
@@ -35,28 +181,17 @@ def build_log_rank_scores(freq_list, *, per_word=False):
|
||||
word_weights[word] = f
|
||||
unique_weights = sorted(set(word_weights.values()), reverse=True)
|
||||
if len(unique_weights) <= 1:
|
||||
word_scores = {w: 1.0 for w in word_weights}
|
||||
word_scores = {w: 0.5 for w in word_weights}
|
||||
else:
|
||||
log_vals = [math.log1p(w) for w in unique_weights]
|
||||
normed = normalize_relative_scores(log_vals)
|
||||
weight_to_score = dict(zip(unique_weights, normed))
|
||||
word_scores = {w: weight_to_score[weight] for w, weight in word_weights.items()}
|
||||
return [word_scores.get(w, 1.0) for w, _ in freq_list]
|
||||
return [word_scores.get(w, 0.5) for w, _ in freq_list]
|
||||
|
||||
safe_freqs = [max(float(freq), 1e-6) for _word, freq in freq_list]
|
||||
log_scores = normalize_relative_scores([math.log1p(freq) for freq in safe_freqs])
|
||||
rank_scores = [1.0 - (idx / max(1, len(freq_list) - 1)) for idx in range(len(freq_list))]
|
||||
|
||||
total_ratio = config.LOG_WEIGHT_RATIO + config.RANK_WEIGHT_RATIO
|
||||
if total_ratio <= 0:
|
||||
return log_scores
|
||||
|
||||
log_ratio = config.LOG_WEIGHT_RATIO / total_ratio
|
||||
rank_ratio = config.RANK_WEIGHT_RATIO / total_ratio
|
||||
return [
|
||||
max(0.0, min(1.0, log_score * log_ratio + rank_score * rank_ratio))
|
||||
for log_score, rank_score in zip(log_scores, rank_scores)
|
||||
]
|
||||
return log_scores
|
||||
|
||||
|
||||
def pick_palette_color(relative_score):
|
||||
@@ -69,76 +204,34 @@ def pick_palette_color(relative_score):
|
||||
return palette[idx]
|
||||
|
||||
|
||||
def _build_layout_sequence(sorted_freq, max_words, layout_order_mode, layout_seed):
|
||||
def _build_layout_sequence(sorted_freq, max_words, layout_seed):
|
||||
if max_words <= 0 or not sorted_freq:
|
||||
return []
|
||||
|
||||
expanded_freq = list(sorted_freq)
|
||||
if len(expanded_freq) < max_words:
|
||||
base_words = expanded_freq[:]
|
||||
if not base_words:
|
||||
return []
|
||||
while len(expanded_freq) < max_words:
|
||||
for item in base_words:
|
||||
if len(expanded_freq) >= max_words:
|
||||
break
|
||||
expanded_freq.append(item)
|
||||
|
||||
expanded_freq = expanded_freq[:max_words]
|
||||
if layout_order_mode == config.LAYOUT_ORDER_MODE_SORTED or len(expanded_freq) <= 1:
|
||||
return expanded_freq
|
||||
|
||||
band_count = min(3, len(expanded_freq))
|
||||
band_size = math.ceil(len(expanded_freq) / band_count)
|
||||
bands = []
|
||||
rng = random.Random(layout_seed)
|
||||
for band_idx in range(band_count):
|
||||
start = band_idx * band_size
|
||||
end = min(len(expanded_freq), start + band_size)
|
||||
band = expanded_freq[start:end]
|
||||
rng.shuffle(band)
|
||||
if band:
|
||||
bands.append(band)
|
||||
|
||||
interleave_pattern = [0, 1, 0, 2]
|
||||
band_positions = [0] * len(bands)
|
||||
base_words = list(sorted_freq)
|
||||
sequence = []
|
||||
|
||||
while len(sequence) < len(expanded_freq):
|
||||
appended = False
|
||||
for pattern_idx in interleave_pattern:
|
||||
if pattern_idx >= len(bands):
|
||||
continue
|
||||
pos = band_positions[pattern_idx]
|
||||
if pos >= len(bands[pattern_idx]):
|
||||
continue
|
||||
sequence.append(bands[pattern_idx][pos])
|
||||
band_positions[pattern_idx] += 1
|
||||
appended = True
|
||||
if len(sequence) >= len(expanded_freq):
|
||||
break
|
||||
if appended:
|
||||
continue
|
||||
for band_idx, band in enumerate(bands):
|
||||
pos = band_positions[band_idx]
|
||||
if pos < len(band):
|
||||
sequence.append(band[pos])
|
||||
band_positions[band_idx] += 1
|
||||
appended = True
|
||||
if len(sequence) >= len(expanded_freq):
|
||||
break
|
||||
if not appended:
|
||||
break
|
||||
|
||||
while len(sequence) < max_words:
|
||||
round_items = []
|
||||
start = 0
|
||||
while start < len(base_words):
|
||||
end = start + 1
|
||||
weight = float(base_words[start][1])
|
||||
while end < len(base_words) and math.isclose(float(base_words[end][1]), weight):
|
||||
end += 1
|
||||
# Start every equal-weight group from a canonical order before
|
||||
# shuffling. A fixed seed must therefore give the same layout
|
||||
# regardless of the row order in the uploaded workbook.
|
||||
group = sorted(base_words[start:end], key=lambda item: str(item[0]))
|
||||
rng.shuffle(group)
|
||||
round_items.extend(group)
|
||||
start = end
|
||||
remaining = max_words - len(sequence)
|
||||
sequence.extend(round_items[:remaining])
|
||||
return sequence
|
||||
|
||||
|
||||
class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
def __init__(self, *args, large_font_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.large_font_ratio = large_font_ratio
|
||||
self.size_scale = size_scale
|
||||
|
||||
def generate_from_frequencies(self, frequencies):
|
||||
if isinstance(frequencies, dict):
|
||||
freq_list = list(frequencies.items())
|
||||
@@ -147,12 +240,12 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
else:
|
||||
raise ValueError("frequencies 必须是字典或 (word, freq) 列表")
|
||||
|
||||
sorted_freq = sorted(freq_list, key=lambda x: x[1], reverse=True)
|
||||
sorted_freq = sorted(freq_list, key=lambda item: (-float(item[1]), str(item[0])))
|
||||
layout_seed = getattr(self, "layout_seed", config.LAYOUT_SEED)
|
||||
layout_sequence = _build_layout_sequence(
|
||||
sorted_freq,
|
||||
self.max_words,
|
||||
config.LAYOUT_ORDER_MODE,
|
||||
config.LAYOUT_SEED,
|
||||
layout_seed,
|
||||
)
|
||||
|
||||
if not layout_sequence:
|
||||
@@ -164,116 +257,120 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
for (w, _f), s in zip(freq_list, per_word_scores):
|
||||
if w not in word_to_score or s > word_to_score[w]:
|
||||
word_to_score[w] = s
|
||||
score_by_index = [word_to_score.get(w, 1.0) for w, _ in layout_sequence]
|
||||
effective_max_font = max(self.min_font_size + 1, int(self.max_font_size * self.size_scale))
|
||||
large_threshold = int(effective_max_font * config.LARGE_FONT_THRESHOLD_RATIO) if config.LIMIT_LARGE_FONTS else None
|
||||
large_limit = int(self.max_words * self.large_font_ratio) if config.LIMIT_LARGE_FONTS else None
|
||||
large_count = 0
|
||||
|
||||
rotation_flags = [np.random.random() > self.prefer_horizontal for _ in layout_sequence]
|
||||
score_by_index = [word_to_score.get(w, 0.5) for w, _ in layout_sequence]
|
||||
seed = layout_seed if layout_seed is not None else config.SEED
|
||||
rng = np.random.default_rng(seed)
|
||||
rotation_flags = [bool(rng.random() > self.prefer_horizontal) for _ in layout_sequence]
|
||||
|
||||
# Dummy draw for textbbox measurement (no actual PIL image needed during placement)
|
||||
_measure_img = Image.new("L", (1, 1))
|
||||
_measure_draw = ImageDraw.Draw(_measure_img)
|
||||
|
||||
base_span = max(1, self.max_font_size - self.min_font_size)
|
||||
# (word, size, rotate) -> exact collision and drawing geometry.
|
||||
# The C++ canvas stores the same tight glyph bitmap that PIL renders;
|
||||
# bbox bearings are carried separately so HD output cannot drift away
|
||||
# from the collision map.
|
||||
glyph_cache = {}
|
||||
|
||||
def measure_and_mask(word, size, rotate):
|
||||
key = (word, size, rotate)
|
||||
cached = glyph_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
font = get_cached_font(self.font_path, size)
|
||||
orientation = Image.ROTATE_90 if rotate else None
|
||||
transposed = ImageFont.TransposedFont(font, orientation=orientation) if orientation else font
|
||||
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
|
||||
glyph_mask = transposed.getmask(word, mode="L")
|
||||
gw, gh = glyph_mask.size
|
||||
if gw <= 0 or gh <= 0:
|
||||
return None
|
||||
# np.array avoids the intermediate bytes() copy that
|
||||
# frombuffer(bytes(...)) would incur.
|
||||
glyph_arr = np.array(glyph_mask, dtype=np.uint8).reshape(gh, gw)
|
||||
pad = max(0, int(self.margin))
|
||||
if pad:
|
||||
padded = np.zeros((gh + 2 * pad, gw + 2 * pad), dtype=np.uint8)
|
||||
padded[pad:pad + gh, pad:pad + gw] = glyph_arr
|
||||
# Reserve a true inter-glyph margin while still allowing
|
||||
# transparent corners and stroke gaps to interlock.
|
||||
collision_arr = np.asarray(
|
||||
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
|
||||
dtype=np.uint8,
|
||||
)
|
||||
stamp_arr = padded
|
||||
else:
|
||||
collision_arr = glyph_arr
|
||||
stamp_arr = glyph_arr
|
||||
result = (
|
||||
collision_arr.shape[0],
|
||||
collision_arr.shape[1],
|
||||
collision_arr,
|
||||
stamp_arr,
|
||||
orientation,
|
||||
int(bbox[0]),
|
||||
int(bbox[1]),
|
||||
pad,
|
||||
)
|
||||
glyph_cache[key] = result
|
||||
return result
|
||||
|
||||
min_font = max(config.MIN_FONT_FLOOR, int(self.min_font_size))
|
||||
max_font = max(min_font, int(self.max_font_size))
|
||||
base_span = max_font - min_font
|
||||
target_font_sizes = []
|
||||
for score in score_by_index:
|
||||
raw_size = self.min_font_size + base_span * score
|
||||
f_size = max(config.MIN_FONT_FLOOR, int(round(raw_size * self.size_scale)))
|
||||
raw_size = min_font + base_span * score
|
||||
f_size = min(max_font, max(min_font, int(round(raw_size))))
|
||||
target_font_sizes.append(f_size)
|
||||
|
||||
gap_fill_list = [] # 收集未成功放置的词,用于第二轮填充
|
||||
|
||||
random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08)))
|
||||
for idx, (word, _freq) in enumerate(layout_sequence):
|
||||
font_size = target_font_sizes[idx]
|
||||
if config.LIMIT_LARGE_FONTS and large_threshold is not None and large_limit is not None:
|
||||
if font_size >= large_threshold and large_count >= large_limit:
|
||||
font_size = max(self.min_font_size, int(large_threshold * config.LARGE_FONT_CAP_RATIO))
|
||||
|
||||
current_size = font_size
|
||||
min_attempt_size = max(self.min_font_size, int(current_size * 0.4))
|
||||
placed = False
|
||||
rotate = rotation_flags[idx]
|
||||
for try_rotate in (rotate, not rotate):
|
||||
measured = measure_and_mask(word, font_size, try_rotate)
|
||||
if measured is None:
|
||||
continue
|
||||
(
|
||||
query_h,
|
||||
query_w,
|
||||
collision_arr,
|
||||
stamp_arr,
|
||||
orientation,
|
||||
bbox_left,
|
||||
bbox_top,
|
||||
pad,
|
||||
) = measured
|
||||
query_seed = int(rng.integers(0, 2**31))
|
||||
large_word = idx < random_large_prefix or score_by_index[idx] >= 0.80
|
||||
placement_mode = 2 if large_word else 1
|
||||
pos = self.grid.place_glyph_exact(
|
||||
collision_arr,
|
||||
stamp_arr,
|
||||
query_h,
|
||||
query_w,
|
||||
query_seed,
|
||||
256,
|
||||
placement_mode,
|
||||
)
|
||||
|
||||
while current_size >= min_attempt_size:
|
||||
orientation = None
|
||||
rotate = rotation_flags[idx]
|
||||
if rotate:
|
||||
orientation = Image.ROTATE_90
|
||||
if pos is None:
|
||||
continue
|
||||
y, x = pos
|
||||
ink_y = y + pad
|
||||
ink_x = x + pad
|
||||
draw_y = ink_y - bbox_top
|
||||
draw_x = ink_x - bbox_left
|
||||
color = pick_palette_color(score_by_index[idx])
|
||||
self.layout_.append((word, font_size, (draw_y, draw_x), orientation, color))
|
||||
placed = True
|
||||
break
|
||||
|
||||
font = get_cached_font(self.font_path, current_size)
|
||||
if orientation:
|
||||
transposed = ImageFont.TransposedFont(font, orientation=orientation)
|
||||
else:
|
||||
transposed = font
|
||||
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
|
||||
w_text = bbox[2] - bbox[0]
|
||||
h_text = bbox[3] - bbox[1]
|
||||
|
||||
query_w = w_text + self.margin
|
||||
query_h = h_text + self.margin
|
||||
|
||||
pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31))
|
||||
|
||||
if pos is not None:
|
||||
y, x = pos
|
||||
draw_y = y + self.margin // 2
|
||||
draw_x = x + self.margin // 2
|
||||
|
||||
# Stamp glyph bitmap into C++ canvas for pixel-accurate collision
|
||||
font = get_cached_font(self.font_path, current_size)
|
||||
if orientation:
|
||||
transposed = ImageFont.TransposedFont(font, orientation=orientation)
|
||||
else:
|
||||
transposed = font
|
||||
glyph_mask = transposed.getmask(word, mode="L")
|
||||
gw, gh = glyph_mask.size
|
||||
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
|
||||
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
|
||||
|
||||
color = pick_palette_color(score_by_index[idx])
|
||||
self.layout_.append((word, current_size, (draw_y, draw_x), orientation, color))
|
||||
|
||||
if config.LIMIT_LARGE_FONTS and large_threshold is not None and current_size >= large_threshold:
|
||||
large_count += 1
|
||||
placed = True
|
||||
break
|
||||
|
||||
current_size -= 2
|
||||
|
||||
if not placed:
|
||||
gap_fill_list.append((word, score_by_index[idx]))
|
||||
|
||||
# ── Gap-filling pass: 用更小的字号填充剩余空隙 ──────────────
|
||||
if gap_fill_list:
|
||||
gap_font_size = max(config.MIN_FONT_FLOOR, int(self.min_font_size * 0.8))
|
||||
if gap_font_size >= config.MIN_FONT_FLOOR:
|
||||
placed_gap = 0
|
||||
for word, score in gap_fill_list:
|
||||
font = get_cached_font(self.font_path, gap_font_size)
|
||||
bbox = _measure_draw.textbbox((0, 0), word, font=font)
|
||||
w_text = bbox[2] - bbox[0]
|
||||
h_text = bbox[3] - bbox[1]
|
||||
query_w = w_text + self.margin
|
||||
query_h = h_text + self.margin
|
||||
|
||||
pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31))
|
||||
if pos is not None:
|
||||
y, x = pos
|
||||
draw_y = y + self.margin // 2
|
||||
draw_x = x + self.margin // 2
|
||||
|
||||
glyph_mask = font.getmask(word, mode="L")
|
||||
gw, gh = glyph_mask.size
|
||||
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
|
||||
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
|
||||
|
||||
color = pick_palette_color(score)
|
||||
self.layout_.append((word, gap_font_size, (draw_y, draw_x), None, color))
|
||||
placed_gap += 1
|
||||
|
||||
if placed_gap > 0:
|
||||
config._warn(f"Gap-filling: 用小字号 {gap_font_size} 额外放置了 {placed_gap}/{len(gap_fill_list)} 个词")
|
||||
# Deliberately do not shrink an individual word. The pipeline
|
||||
# treats a short layout as a failed batch and retries every word
|
||||
# at one uniformly scaled size range.
|
||||
|
||||
return self
|
||||
|
||||
@@ -287,6 +384,57 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
draw.text((x, y), word, font=font, fill=color)
|
||||
return img
|
||||
|
||||
def _iter_svg_paths(self):
|
||||
"""Build SVG path data once per layout entry, with glyph-shape cache."""
|
||||
# Cache by (word, size, orient): path geometry is identical; only translate differs.
|
||||
shape_cache = {}
|
||||
for word, size, (y, x), orient, color in self.layout_:
|
||||
key = (word, int(size), bool(orient))
|
||||
cached = shape_cache.get(key)
|
||||
if cached is None:
|
||||
try:
|
||||
path_d, origin_tx, origin_ty, bbox = build_svg_text_path_cached(
|
||||
word, size, 0, 0, self.font_path, orient
|
||||
)
|
||||
except Exception as exc:
|
||||
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
|
||||
continue
|
||||
# origin_tx/ty place the glyph so its top-left is at (0,0)
|
||||
shape_cache[key] = (path_d, origin_tx, origin_ty)
|
||||
cached = shape_cache[key]
|
||||
path_d, origin_tx, origin_ty = cached
|
||||
# Shift from (0,0) origin to actual layout position
|
||||
tx = origin_tx + x
|
||||
ty = origin_ty + y
|
||||
yield path_d, tx, ty, color
|
||||
|
||||
def export_svgs(self, fill_filename, stroke_color="#000000", stroke_width=1.0):
|
||||
"""Write fill + stroke SVG in one pass (path geometry built once)."""
|
||||
stroke_filename = str(
|
||||
Path(fill_filename).with_name(Path(fill_filename).stem + "_stroke" + Path(fill_filename).suffix)
|
||||
)
|
||||
background = self.background_color
|
||||
header = (
|
||||
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
|
||||
f'xmlns="http://www.w3.org/2000/svg">\n'
|
||||
)
|
||||
with open(fill_filename, "w", encoding="utf-8") as ff, open(stroke_filename, "w", encoding="utf-8") as sf:
|
||||
ff.write(header)
|
||||
sf.write(header)
|
||||
ff.write(f'<rect width="100%" height="100%" fill="{background}"/>\n')
|
||||
sf.write('<rect width="100%" height="100%" fill="none"/>\n')
|
||||
for path_d, tx, ty, color in self._iter_svg_paths():
|
||||
transform = f'translate({tx:.3f} {ty:.3f}) scale(1 -1)'
|
||||
ff.write(f'<path d="{path_d}" transform="{transform}" fill="{color}"/>\n')
|
||||
sf.write(
|
||||
f'<path d="{path_d}" transform="{transform}" '
|
||||
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
|
||||
)
|
||||
ff.write("</svg>\n")
|
||||
sf.write("</svg>\n")
|
||||
return stroke_filename
|
||||
|
||||
def to_svg(self, filename):
|
||||
background = self.background_color
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
@@ -295,15 +443,8 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
f'xmlns="http://www.w3.org/2000/svg">\n'
|
||||
)
|
||||
f.write(f'<rect width="100%" height="100%" fill="{background}"/>\n')
|
||||
|
||||
for word, size, (y, x), orient, color in self.layout_:
|
||||
try:
|
||||
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
|
||||
except Exception as exc:
|
||||
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
|
||||
continue
|
||||
f.write(f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{color}"/>\n')
|
||||
|
||||
for path_d, tx, ty, color in self._iter_svg_paths():
|
||||
f.write(f'<path d="{path_d}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{color}"/>\n')
|
||||
f.write("</svg>\n")
|
||||
|
||||
def to_svg_stroke(self, filename, stroke_color="#000000", stroke_width=1.0):
|
||||
@@ -313,20 +454,13 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
|
||||
f'xmlns="http://www.w3.org/2000/svg">\n'
|
||||
)
|
||||
f.write(f'<rect width="100%" height="100%" fill="none"/>\n')
|
||||
|
||||
for word, size, (y, x), orient, _color in self.layout_:
|
||||
try:
|
||||
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
|
||||
except Exception as exc:
|
||||
config._warn(f"SVG stroke path 导出失败,跳过词条: {word}, error={exc}")
|
||||
continue
|
||||
f.write('<rect width="100%" height="100%" fill="none"/>\n')
|
||||
for path_d, tx, ty, _color in self._iter_svg_paths():
|
||||
f.write(
|
||||
f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" '
|
||||
f'<path d="{path_d}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" '
|
||||
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
|
||||
)
|
||||
|
||||
f.write("</svg>\n")
|
||||
|
||||
def to_svg_dotfill(self, filename, dot_spacing=10, dot_radius=2, dot_color="#000000"):
|
||||
@@ -471,18 +605,6 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
|
||||
f.write(' </clipPath>\n')
|
||||
|
||||
|
||||
def build_svg_text_path(word, size, x, y, font_path, orient):
|
||||
path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size)
|
||||
if orient:
|
||||
path = path.transformed(Affine2D().rotate_deg(-90))
|
||||
bbox = path.get_extents()
|
||||
tx = x - bbox.xmin
|
||||
ty = y + bbox.ymax
|
||||
# 返回变换后的 Path(已定位到画布坐标)以及 SVG 用的偏移量
|
||||
transformed = path.transformed(Affine2D().scale(1, -1).translate(tx, ty))
|
||||
return mpl_path_to_svg_d(path), tx, ty, transformed
|
||||
|
||||
|
||||
def mpl_path_to_svg_d(path):
|
||||
parts = []
|
||||
for vertices, code in path.iter_segments():
|
||||
@@ -557,4 +679,3 @@ def render_path_occupancy(layout_data, canvas_shape, font_path):
|
||||
|
||||
# 最终蒙版:文字笔画=1,外部和字内空洞=0
|
||||
return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user