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:
2026-07-26 18:32:25 +08:00
co-authored by Claude Sonnet 5
parent bf2b138007
commit 1d17b5e20d
24 changed files with 2600 additions and 1283 deletions
+330 -209
View File
@@ -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)