feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表

This commit is contained in:
2026-08-13 14:22:48 +08:00
parent 1d17b5e20d
commit e518540235
32 changed files with 3525 additions and 592 deletions
+220 -83
View File
@@ -42,65 +42,169 @@ def _load_ft_font(font_path):
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.
"""Return (path_d, tx, ty, None) for the whole word as a single path.
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.
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 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
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 _build_shape_fonttools(word, size, font_path, orient):
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)
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
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 "", 0.0, 0.0
return "", advance, 0.0, 0.0, 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
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):
@@ -137,19 +241,6 @@ def _path_bbox(path_d):
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
@@ -324,8 +415,44 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
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):
# "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]
@@ -344,8 +471,7 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
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
placement_mode = 2 if idx in large_index_set else 1
pos = self.grid.place_glyph_exact(
collision_arr,
stamp_arr,
@@ -371,6 +497,10 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
# 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
@@ -385,28 +515,27 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
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 = {}
"""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_:
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
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)."""
@@ -500,13 +629,21 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
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:
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
text_paths.append((path, tx, ty))
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(