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>
682 lines
29 KiB
Python
682 lines
29 KiB
Python
import math
|
|
import random
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
from matplotlib.path import Path as MplPath
|
|
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:
|
|
return []
|
|
v_min = min(values)
|
|
v_max = max(values)
|
|
if math.isclose(v_min, v_max):
|
|
# 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]
|
|
|
|
|
|
def build_log_rank_scores(freq_list, *, per_word=False):
|
|
if not freq_list:
|
|
return []
|
|
|
|
if per_word:
|
|
word_weights = {}
|
|
for word, freq in freq_list:
|
|
f = max(float(freq), 1e-6)
|
|
if word not in word_weights or f > word_weights[word]:
|
|
word_weights[word] = f
|
|
unique_weights = sorted(set(word_weights.values()), reverse=True)
|
|
if len(unique_weights) <= 1:
|
|
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, 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])
|
|
return log_scores
|
|
|
|
|
|
def pick_palette_color(relative_score):
|
|
if config.FONT_COLOR:
|
|
return config.FONT_COLOR
|
|
palette = config.LIGHT_COLOR_PALETTE if config.FILL_ON == "WHITE" else config.DARK_COLOR_PALETTE
|
|
if not palette:
|
|
return "#111111"
|
|
idx = min(len(palette) - 1, max(0, int(round((1.0 - relative_score) * (len(palette) - 1)))))
|
|
return palette[idx]
|
|
|
|
|
|
def _build_layout_sequence(sorted_freq, max_words, layout_seed):
|
|
if max_words <= 0 or not sorted_freq:
|
|
return []
|
|
|
|
rng = random.Random(layout_seed)
|
|
base_words = list(sorted_freq)
|
|
sequence = []
|
|
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 generate_from_frequencies(self, frequencies):
|
|
if isinstance(frequencies, dict):
|
|
freq_list = list(frequencies.items())
|
|
elif isinstance(frequencies, list):
|
|
freq_list = frequencies
|
|
else:
|
|
raise ValueError("frequencies 必须是字典或 (word, freq) 列表")
|
|
|
|
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,
|
|
layout_seed,
|
|
)
|
|
|
|
if not layout_sequence:
|
|
return self
|
|
|
|
self.layout_ = []
|
|
per_word_scores = build_log_rank_scores(freq_list, per_word=True)
|
|
word_to_score = {}
|
|
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, 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)
|
|
|
|
# (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 = min_font + base_span * score
|
|
f_size = min(max_font, max(min_font, int(round(raw_size))))
|
|
target_font_sizes.append(f_size)
|
|
|
|
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]
|
|
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,
|
|
)
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
def to_image(self):
|
|
img = Image.new(self.mode, (self.width, self.height), self.background_color)
|
|
draw = ImageDraw.Draw(img)
|
|
for word, size, (y, x), orient, color in self.layout_:
|
|
font = get_cached_font(self.font_path, size)
|
|
if orient:
|
|
font = ImageFont.TransposedFont(font, orientation=orient)
|
|
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:
|
|
f.write(
|
|
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="{background}"/>\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):
|
|
"""生成描边版 SVG,适合激光雕刻机使用(描边路径,无填充)。"""
|
|
with open(filename, "w", encoding="utf-8") as f:
|
|
f.write(
|
|
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('<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_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"):
|
|
"""生成点阵填充 SVG:文字区域用密排小圆点填充,适合激光雕刻逐点打标。"""
|
|
from .render import render_layout_occupancy
|
|
|
|
occ = render_layout_occupancy(self.layout_, (self.height, self.width), self.font_path)
|
|
occ_arr = np.array(occ)
|
|
|
|
with open(filename, "w", encoding="utf-8") as f:
|
|
f.write(
|
|
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')
|
|
|
|
half = dot_spacing / 2
|
|
dot_count = 0
|
|
h, w = occ_arr.shape
|
|
for gy in range(0, h, dot_spacing):
|
|
for gx in range(0, w, dot_spacing):
|
|
cy = min(gy + int(half), h - 1)
|
|
cx = min(gx + int(half), w - 1)
|
|
if occ_arr[cy, cx]:
|
|
f.write(
|
|
f'<circle cx="{cx}" cy="{cy}" r="{dot_radius}" '
|
|
f'fill="{dot_color}" stroke="none"/>\n'
|
|
)
|
|
dot_count += 1
|
|
|
|
f.write("</svg>\n")
|
|
return dot_count
|
|
|
|
def to_svg_custom(self, filename, fill_mode="fill", do_stroke=False,
|
|
dot_spacing=10, dot_radius=2, color="#000000",
|
|
line_spacing=6, line_width=1, line_angle=0,
|
|
ring_radius=3, ring_width=1, ring_spacing=8):
|
|
"""统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。"""
|
|
# 预先构建所有文字路径(fill / dot 模式共用)
|
|
text_paths = []
|
|
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)
|
|
text_paths.append((path, tx, ty))
|
|
except Exception as exc:
|
|
config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}")
|
|
|
|
with open(filename, "w", encoding="utf-8") as f:
|
|
f.write(
|
|
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')
|
|
|
|
if fill_mode == "dot":
|
|
# 点阵模式:用 SVG pattern 平铺圆点 + clipPath 裁剪到文字形状
|
|
f.write('<defs>\n')
|
|
f.write(f' <pattern id="dot-pat" x="0" y="0" width="{dot_spacing}" height="{dot_spacing}" patternUnits="userSpaceOnUse">\n')
|
|
half = dot_spacing / 2
|
|
f.write(f' <circle cx="{half}" cy="{half}" r="{dot_radius}" fill="{color}"/>\n')
|
|
f.write(' </pattern>\n')
|
|
self._write_text_clip(f, text_paths)
|
|
f.write('</defs>\n')
|
|
f.write(f'<rect width="{self.width}" height="{self.height}" fill="url(#dot-pat)" clip-path="url(#text-clip)"/>\n')
|
|
|
|
elif fill_mode == "line":
|
|
# 线条填充:用 matplotlib Path 渲染占用蒙版(与 SVG 完全对齐)
|
|
import math as _m
|
|
occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path)
|
|
angle = line_angle % 360
|
|
rad = _m.radians(angle)
|
|
cos_a, sin_a = _m.cos(rad), _m.sin(rad)
|
|
h, w = occ.shape
|
|
step = 1 # 逐像素采样,保证线段连续
|
|
# 垂直方向的总范围(确保覆盖整个画布)
|
|
perp_max = abs(h * cos_a) + abs(w * sin_a)
|
|
n_lines = max(1, int(perp_max / line_spacing) + 1)
|
|
sw = f'{line_width:g}'
|
|
path_parts = []
|
|
for i in range(n_lines):
|
|
d0 = (i - n_lines // 2) * line_spacing
|
|
sx = -d0 * sin_a
|
|
sy = d0 * cos_a
|
|
n_steps = int(perp_max) + 1
|
|
run_start = None
|
|
for s in range(n_steps + 1):
|
|
px = sx + s * step * cos_a
|
|
py = sy + s * step * sin_a
|
|
ix, iy = int(round(px)), int(round(py))
|
|
inside = (0 <= iy < h and 0 <= ix < w and occ[iy, ix])
|
|
if inside:
|
|
if run_start is None:
|
|
run_start = (px, py)
|
|
else:
|
|
if run_start is not None:
|
|
ex = px - step * cos_a
|
|
ey = py - step * sin_a
|
|
path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}')
|
|
run_start = None
|
|
if run_start is not None:
|
|
ex = sx + n_steps * step * cos_a
|
|
ey = sy + n_steps * step * sin_a
|
|
path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}')
|
|
if path_parts:
|
|
f.write(f'<path d="{" ".join(path_parts)}" fill="none" stroke="{color}" stroke-width="{sw}" stroke-linecap="round"/>\n')
|
|
|
|
elif fill_mode == "ring":
|
|
# 空心圆点填充:闭合路径,激光机可描一圈
|
|
occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path)
|
|
h, w = occ.shape
|
|
r = ring_radius
|
|
sw = f'{ring_width:g}'
|
|
circle_parts = []
|
|
for gy in range(r, h - r, ring_spacing):
|
|
for gx in range(r, w - r, ring_spacing):
|
|
if not occ[gy, gx]:
|
|
continue
|
|
lx = gx - r
|
|
rx = gx + r
|
|
circle_parts.append(
|
|
f'M{lx} {gy}A{r} {r} 0 1 0 {rx} {gy}A{r} {r} 0 1 0 {lx} {gy}Z'
|
|
)
|
|
if circle_parts:
|
|
f.write(f'<path d="{" ".join(circle_parts)}" fill="none" stroke="{color}" stroke-width="{sw}"/>\n')
|
|
|
|
# 只有 fill 模式和显式描边时才输出 matplotlib 文字路径
|
|
# ring/line 模式用 PIL occupancy mask 生成填充,不需要文字轮廓
|
|
if fill_mode == "fill" or do_stroke:
|
|
for path, tx, ty in text_paths:
|
|
fill_attr = color if fill_mode == "fill" else "none"
|
|
stroke_attr = f'stroke="{color}" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"' if do_stroke else ""
|
|
f.write(f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{fill_attr}" {stroke_attr}/>\n')
|
|
|
|
f.write("</svg>\n")
|
|
|
|
@staticmethod
|
|
def _write_text_clip(f, text_paths):
|
|
"""将文字路径写入 <clipPath id="text-clip">(调用方负责 <defs> 开闭)。"""
|
|
f.write(' <clipPath id="text-clip">\n')
|
|
for path, tx, ty in text_paths:
|
|
f.write(f' <path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)"/>\n')
|
|
f.write(' </clipPath>\n')
|
|
|
|
|
|
def mpl_path_to_svg_d(path):
|
|
parts = []
|
|
for vertices, code in path.iter_segments():
|
|
if code == MplPath.MOVETO:
|
|
x, y = vertices
|
|
parts.append(f"M{x:.3f} {y:.3f}")
|
|
elif code == MplPath.LINETO:
|
|
x, y = vertices
|
|
parts.append(f"L{x:.3f} {y:.3f}")
|
|
elif code == MplPath.CURVE3:
|
|
x1, y1, x2, y2 = vertices
|
|
parts.append(f"Q{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f}")
|
|
elif code == MplPath.CURVE4:
|
|
x1, y1, x2, y2, x3, y3 = vertices
|
|
parts.append(
|
|
f"C{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f} {x3:.3f} {y3:.3f}"
|
|
)
|
|
elif code == MplPath.CLOSEPOLY:
|
|
parts.append("Z")
|
|
return " ".join(parts)
|
|
|
|
|
|
def render_path_occupancy(layout_data, canvas_shape, font_path):
|
|
"""渲染文字占用蒙版:字形笔画=1,字内空洞(如口)=0,外部=0。
|
|
|
|
使用 PIL 渲染文字蒙版(与画布坐标完全对齐)+ 边界泛洪填充来区分外部区域与字内空洞。
|
|
layout_data: [(word, size, (y, x), orient, color), ...] 同 self.layout_
|
|
"""
|
|
from collections import deque
|
|
|
|
h, w = canvas_shape
|
|
if not layout_data:
|
|
return np.zeros((h, w), dtype=np.uint8)
|
|
|
|
# 用 PIL 渲染文字蒙版(坐标系与 to_image() 完全一致)
|
|
mask = Image.new("L", (w, h), 0)
|
|
draw = ImageDraw.Draw(mask)
|
|
for word, size, (y, x), orient, _color in layout_data:
|
|
font = get_cached_font(font_path, size)
|
|
if orient:
|
|
font = ImageFont.TransposedFont(font, orientation=orient)
|
|
draw.text((x, y), word, font=font, fill=255)
|
|
|
|
occ_raw = (np.array(mask) > 127).astype(np.uint8)
|
|
|
|
# 泛洪填充:从边框出发标记所有与外部连通的白色区域
|
|
# 口 等闭合字符的内部空洞不会与边框连通,因此正确保留为空
|
|
outside = np.zeros_like(occ_raw, dtype=np.uint8)
|
|
q = deque()
|
|
for x in range(w):
|
|
if occ_raw[0, x]:
|
|
q.append((0, x))
|
|
outside[0, x] = 1
|
|
if occ_raw[h - 1, x]:
|
|
q.append((h - 1, x))
|
|
outside[h - 1, x] = 1
|
|
for y in range(1, h - 1):
|
|
if occ_raw[y, 0]:
|
|
q.append((y, 0))
|
|
outside[y, 0] = 1
|
|
if occ_raw[y, w - 1]:
|
|
q.append((y, w - 1))
|
|
outside[y, w - 1] = 1
|
|
|
|
while q:
|
|
cy, cx = q.popleft()
|
|
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
|
ny, nx = cy + dy, cx + dx
|
|
if 0 <= ny < h and 0 <= nx < w and occ_raw[ny, nx] and not outside[ny, nx]:
|
|
outside[ny, nx] = 1
|
|
q.append((ny, nx))
|
|
|
|
# 最终蒙版:文字笔画=1,外部和字内空洞=0
|
|
return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8)
|