Files
wordcloud/backend/core/layout.py
T

819 lines
35 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) for the whole word as a single path.
Kept for callers that want one path per word. Internally this concatenates
the per-character parts, which are cached and shared across every word that
reuses the character.
"""
parts, tx0, ty0 = build_svg_word_parts(word, size, font_path, orient)
if not parts:
return "", x, y, None
if len(parts) == 1 and parts[0][1] == 0.0 and parts[0][2] == 0.0:
return parts[0][0], tx0 + x, ty0 + y, None
# Only reached when a caller insists on one path per word; the per-part
# translate has to be baked into the coordinates, so this is the slow path.
merged = " ".join(_translate_path_d(d, dx, dy) for d, dx, dy in parts)
return merged, tx0 + x, ty0 + y, None
def build_svg_word_parts(word, size, font_path, orient):
"""Return ([(path_d, dx, dy), ...], tx0, ty0) for one word.
Each part is a per-character outline cached at cursor 0 and reused verbatim;
dx/dy carry that character's position within the word. A caller places a
part with translate(tx0 + x + dx, ty0 + y + dy) scale(1, -1).
Caching per character rather than per word is what makes export cheap: a
roster of 750 Chinese names contains only a few dozen distinct characters,
so the outline drawing and the path-string formatting run a few dozen times
instead of once per name.
"""
key = (font_path, word, int(size), bool(orient))
cached = _SVG_SHAPE_CACHE.get(key)
if cached is not None:
return cached
parts = []
cursor = 0.0
xmin = ymin = float("inf")
xmax = ymax = float("-inf")
for ch in word:
shape = _char_shape(ch, size, font_path, orient)
if shape is None:
continue
path_d, advance, cxmin, cymin, cxmax, cymax = shape
if path_d:
# A character's outline is cached at cursor 0; the cursor becomes a
# translate offset so the cached string is reused byte for byte.
# Horizontal runs advance in +x, rotated runs in +y (the rotated
# glyph transform subtracts the cursor from y, and the outer
# scale(1, -1) flips that back to +y).
dx, dy = (0.0, cursor) if orient else (cursor, 0.0)
parts.append((path_d, dx, dy))
if orient:
xmin = min(xmin, cxmin)
xmax = max(xmax, cxmax)
ymin = min(ymin, cymin - cursor)
ymax = max(ymax, cymax - cursor)
else:
xmin = min(xmin, cxmin + cursor)
xmax = max(xmax, cxmax + cursor)
ymin = min(ymin, cymin)
ymax = max(ymax, cymax)
cursor += advance
if not parts:
result = ([], 0.0, 0.0)
else:
# Offsets placing the run's top-left at (0,0) under
# translate(tx,ty) scale(1,-1).
result = (parts, -xmin, ymax)
_SVG_SHAPE_CACHE[key] = result
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)
return result
def _char_shape(ch, size, font_path, orient):
"""Return (path_d, advance, xmin, ymin, xmax, ymax) for one character.
The outline is drawn at cursor 0 and scaled to `size`, so the same string is
valid at every position the character appears in.
"""
key = (font_path, ch, int(size), bool(orient))
cached = _FT_CHAR_PATH_CACHE.get(key)
if cached is not None:
return cached
try:
cached = _build_char_fonttools(ch, size, font_path, orient)
except Exception:
cached = _build_char_matplotlib(ch, size, font_path, orient)
_FT_CHAR_PATH_CACHE[key] = cached
return cached
def _build_char_fonttools(ch, 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)
gname = cmap.get(ord(ch))
if not gname or gname not in glyph_set:
return None
glyph = glyph_set[gname]
pen = SVGPathPen(glyph_set)
if orient:
# Horizontal layout then rotate -90° around the origin. The cursor term
# that used to live in dy is applied by the caller as a translate.
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, 0))
else:
tp = TransformPen(pen, Transform(scale, 0, 0, scale, 0, 0))
glyph.draw(tp)
path_d = pen.getCommands()
advance = float(glyph.width) * scale
if not path_d:
return "", advance, 0.0, 0.0, 0.0, 0.0
xmin, ymin, xmax, ymax = _path_bbox(path_d)
return path_d, advance, xmin, ymin, xmax, ymax
def _build_char_matplotlib(ch, size, font_path, orient):
from matplotlib.textpath import TextPath
path = TextPath((0, 0), ch, prop=get_font_properties(font_path, size), size=size)
if orient:
path = path.transformed(Affine2D().rotate_deg(-90))
bbox = path.get_extents()
path_d = mpl_path_to_svg_d(path)
# matplotlib gives no advance width; the ink bbox is the best stand-in.
advance = (bbox.ymax - bbox.ymin) if orient else (bbox.xmax - bbox.xmin)
return path_d, advance, bbox.xmin, bbox.ymin, bbox.xmax, bbox.ymax
def _translate_path_d(path_d, dx, dy):
"""Shift every coordinate pair in an SVG path string by (dx, dy).
Only used by the single-path-per-word compatibility path; the fast export
route carries dx/dy in the element transform instead.
"""
if not dx and not dy:
return path_d
import re
tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)
out = []
i = 0
while i < len(tokens):
t = tokens[i]
if not t.isalpha():
i += 1
continue
out.append(t)
i += 1
coords = []
while i < len(tokens) and not tokens[i].isalpha():
coords.append(float(tokens[i]))
i += 1
for j, v in enumerate(coords):
out.append(f"{v + (dx if j % 2 == 0 else dy):g}")
return " ".join(out)
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_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)
# "Large" words go down first by random probe (mode 2), then everything
# else spirals out from the centre to fill around them (mode 1). Which
# words count as large is decided by their actual font size, not by
# their position in the shuffled sequence: the old rule took the first
# 8% of the sequence, which under equal weights is an arbitrary set of
# same-size words, so "large" placement was applied to words that were
# not large at all. When every word is the same size (the equal-weight
# case) there is no large tier and everything spirals, which is the
# correct degenerate behaviour.
large_font_cutoff = min_font + base_span * 0.80
large_indices = [
idx for idx, size in enumerate(target_font_sizes)
if base_span > 0 and size >= large_font_cutoff
]
# Placing the large words before the small ones matters: they need whole
# empty regions to land in, and once the spiral has packed the canvas
# there are none left. Ordering is by index within each tier so a given
# layout_seed still reproduces exactly.
large_index_set = set(large_indices)
placement_order = large_indices + [
idx for idx in range(len(layout_sequence)) if idx not in large_index_set
]
# A word is only reported unplaced after the spiral, the random probes
# and a full exhaustive scan have all failed, so a single failure proves
# no legal position exists for it at this size -- and since the pipeline
# requires every word, the whole batch is already doomed. `max_failures`
# lets the caller stop right there instead of finishing the batch, which
# is what makes the scale search cheap: an unplaceable word costs about
# ten times a placeable one (it pays the full search before giving up),
# so a doomed batch run to completion is by far the most expensive thing
# the pipeline can do. Left as None, the batch runs to the end and packs
# in as many words as it can.
max_failures = getattr(self, "max_failures", None)
failures = 0
for idx in placement_order:
word, _freq = layout_sequence[idx]
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))
placement_mode = 2 if idx in large_index_set 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.
if not placed:
failures += 1
if max_failures is not None and failures >= max_failures:
break
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):
"""Yield (path_d, tx, ty, color), one entry per glyph.
A word contributes one entry per character. Each path string comes
straight from the per-character cache and the character's position
within the word rides along in tx/ty, so no path data is rebuilt or
re-parsed per word.
"""
for word, size, (y, x), orient, color in self.layout_:
try:
parts, origin_tx, origin_ty = build_svg_word_parts(
word, size, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
# origin_tx/ty place the run's top-left at (0,0); x/y move it to the
# layout position; dx/dy offset the character within the run.
base_tx = origin_tx + x
base_ty = origin_ty + y
for path_d, dx, dy in parts:
yield path_d, base_tx + dx, base_ty + dy, 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 模式共用)
# One entry per glyph rather than per word: each character's outline is
# taken straight from the shared cache, with its position in the run
# carried in tx/ty. Consumers below only ever place these as separate
# <path> elements, so splitting a word costs nothing.
text_paths = []
for word, size, (y, x), orient, _color in self.layout_:
try:
parts, origin_tx, origin_ty = build_svg_word_parts(
word, size, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}")
continue
for path, dx, dy in parts:
text_paths.append((path, origin_tx + x + dx, origin_ty + y + dy))
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)