feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表
This commit is contained in:
+220
-83
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user