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'\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'\n') sf.write('\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'\n') sf.write( f'\n' ) ff.write("\n") sf.write("\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'\n' ) f.write(f'\n') for path_d, tx, ty, color in self._iter_svg_paths(): f.write(f'\n') f.write("\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'\n' ) f.write('\n') for path_d, tx, ty, _color in self._iter_svg_paths(): f.write( f'\n' ) f.write("\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'\n' ) f.write(f'\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'\n' ) dot_count += 1 f.write("\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'\n' ) f.write(f'\n') if fill_mode == "dot": # 点阵模式:用 SVG pattern 平铺圆点 + clipPath 裁剪到文字形状 f.write('\n') f.write(f' \n') half = dot_spacing / 2 f.write(f' \n') f.write(' \n') self._write_text_clip(f, text_paths) f.write('\n') f.write(f'\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'\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'\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'\n') f.write("\n") @staticmethod def _write_text_clip(f, text_paths): """将文字路径写入 (调用方负责 开闭)。""" f.write(' \n') for path, tx, ty in text_paths: f.write(f' \n') f.write(' \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)