"""SVG diagram generation library for book illustrations. Style: black/white/grayscale for B&W printing. - White (#fff) backgrounds - Light gray (#f0f0f0) box fills - Medium gray (#d0d0d0) secondary fills - Dark gray (#999) emphasis fills - Black (#333) borders and text - 2px stroke, 6px rounded corners - Sans-serif fonts (20px body, 16px small, 24px title) - Designed for print: readable at 50-60% scaling """ import html import math import os import re import unicodedata COLORS = { 'white': '#ffffff', 'light': '#f0f0f0', 'medium': '#d0d0d0', 'dark': '#999999', 'darker': '#666666', 'border': '#333333', 'text': '#333333', 'text_light': '#666666', 'bg': '#ffffff', 'code_bg': '#f5f5f5', } FONT = ( "'Noto Naskh Arabic', Amiri, 'Noto Sans Arabic', " "Arial, 'Helvetica Neue', Helvetica, sans-serif" ) MONO = "'Courier New', Courier, monospace" STROKE_W = 2 CORNER_R = 6 FS_TITLE = 24 FS_BODY = 20 FS_SMALL = 16 FS_TINY = 14 FS_LABEL = 16 # Per academic convention: the figure itself does not include a title; the title is written in the main text. # When OMIT_TITLE=True, any 'title-type' text with font_size==FS_TITLE (except short symbols like # VS/→/+) is treated as a figure title and not rendered—regardless of whether it is at the top or middle of the figure (section titles # of multi-panel figures are also removed). Short symbols are preserved via the TITLE_MIN_LEN length threshold. OMIT_TITLE = True TITLE_Y_THRESHOLD = 60 # Kept for backward compatibility; no longer relied upon independently TITLE_MIN_LEN = 4 # Only FS_TITLE text with length >= this value is considered a title and removed TITLE_CROP_PX = 40 def _escape(s): return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') # ── Text width estimation ────────────────────────────────────────────── # Approximate advance widths for Helvetica/Arial (units per 1000 em). Used to # fit text inside boxes/badges so the (wider) English translations do not # overflow shapes that were originally sized for compact CJK text. _CHAR_W = { ' ': 278, '!': 278, '"': 355, '#': 556, '$': 556, '%': 889, '&': 667, "'": 191, '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278, '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556, '8': 556, '9': 556, ':': 278, ';': 278, '<': 584, '=': 584, '>': 584, '?': 556, '@': 1015, 'A': 667, 'B': 667, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778, 'H': 722, 'I': 278, 'J': 500, 'K': 667, 'L': 556, 'M': 833, 'N': 722, 'O': 778, 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944, 'X': 667, 'Y': 667, 'Z': 611, '[': 278, '\\': 278, ']': 278, '^': 469, '_': 556, '`': 333, 'a': 556, 'b': 556, 'c': 500, 'd': 556, 'e': 556, 'f': 278, 'g': 556, 'h': 556, 'i': 222, 'j': 222, 'k': 500, 'l': 222, 'm': 833, 'n': 556, 'o': 556, 'p': 556, 'q': 556, 'r': 333, 's': 500, 't': 278, 'u': 556, 'v': 500, 'w': 722, 'x': 500, 'y': 500, 'z': 500, '{': 334, '|': 260, '}': 334, '~': 584, } # Narrow / specific non-ASCII glyphs (per 1000 em). _SPECIAL_W = { '·': 300, '°': 400, '‘': 278, '’': 278, '“': 500, '”': 500, '–': 556, '≈': 584, '×': 584, '÷': 584, '…': 1000, '—': 1000, '•': 400, '′': 278, '£': 556, '€': 556, '¥': 556, '§': 556, '™': 1000, '®': 737, '©': 737, } def _is_wide(ch): """Return True for glyphs that render roughly one full em wide (CJK, kana, circled numbers, geometric shapes, check marks, arrows, etc.).""" o = ord(ch) return ( 0x1100 <= o <= 0x115F or # Hangul Jamo 0x2460 <= o <= 0x24FF or # enclosed alphanumerics ①②③ 0x2500 <= o <= 0x257F or # box drawing 0x25A0 <= o <= 0x25FF or # geometric shapes △▲■ 0x2600 <= o <= 0x27BF or # misc symbols & dingbats ✓✗★ 0x2E80 <= o <= 0xA4CF or # CJK, kana, radicals 0xAC00 <= o <= 0xD7A3 or # Hangul syllables 0xF900 <= o <= 0xFAFF or # CJK compatibility 0xFE30 <= o <= 0xFE4F or # CJK compatibility forms 0xFF00 <= o <= 0xFF60 or # fullwidth forms 0xFFE0 <= o <= 0xFFE6 or # fullwidth signs o in (0x2190, 0x2191, 0x2192, 0x2193, 0x21D2, 0x2194) # arrows ) def _char_w(ch, mono=False): if unicodedata.category(ch) in {'Mn', 'Me'}: return 0 if mono: return 600 if ch in _SPECIAL_W: return _SPECIAL_W[ch] if _is_wide(ch): return 1000 if ord(ch) < 0x100: return _CHAR_W.get(ch, 556) if ( 0x0600 <= ord(ch) <= 0x06FF or 0x0750 <= ord(ch) <= 0x077F or 0x08A0 <= ord(ch) <= 0x08FF or 0xFB50 <= ord(ch) <= 0xFDFF or 0xFE70 <= ord(ch) <= 0xFEFF ): # Arabic letters have narrower contextual advances than the generic # non-Latin estimate, and shaping shortens many joined sequences. return 450 return 600 def _text_width(s, font_size, bold=False, mono=False): """Estimated rendered width of a single line of text, in pixels.""" total = sum(_char_w(ch, mono) for ch in str(s)) px = total / 1000.0 * font_size return px * 1.045 if bold else px def _units(s): """Split a string into wrap units: latin words, single spaces, and single wide chars (each wide char is an independent break opportunity).""" units = [] prev = None # 'word' | 'space' | 'wide' for ch in s: if ch == ' ': units.append(' ') prev = 'space' elif _is_wide(ch): units.append(ch) prev = 'wide' else: if prev == 'word': units[-1] += ch else: units.append(ch) prev = 'word' return units def _wrap_line(s, avail_w, font_size, bold=False, mono=False): """Greedy word-wrap of one logical line to fit avail_w pixels.""" if avail_w <= 0 or _text_width(s, font_size, bold, mono) <= avail_w: return [s] lines = [] cur = '' for u in _units(s): if u == ' ': candidate = cur + ' ' if cur else '' else: candidate = cur + u if cur == '' or _text_width(candidate, font_size, bold, mono) <= avail_w: cur = candidate else: lines.append(cur.rstrip()) cur = '' if u == ' ' else u if cur.strip(): lines.append(cur.rstrip()) return lines or [''] def _fit_font(s, avail_w, font_size, bold=False, mono=False, min_size=8): """Shrink font_size until the (unwrapped) string fits avail_w.""" fs = font_size while fs > min_size and _text_width(s, fs, bold, mono) > avail_w: fs -= 0.5 return fs def _extent(x, w, anchor): """Return (left, right) pixel extent of a text run of width w anchored at x.""" if anchor == 'start': return x, x + w if anchor == 'end': return x - w, x return x - w / 2, x + w / 2 # middle # ── In-place overflow correction ─────────────────────────────────────── # Shrinks any that overflows its smallest containing or the # canvas. Only the font-size is changed (never positions), so it is safe and # idempotent. Used both by SVG.render() (so generated figures self-correct # standalone labels) and by fit_svg_text.py (to repair static/orphaned SVGs # that no longer have a generator). _TEXT_TAG = re.compile(r']*)>(.*?)', re.S) _RECT_TAG = re.compile( r']*?x="([-\d.]+)"[^>]*?y="([-\d.]+)"[^>]*?' r'width="([-\d.]+)"[^>]*?height="([-\d.]+)"' ) _VIEWBOX = re.compile(r'viewBox="([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+)"') _ATTR = lambda attrs, name: (re.search(name + r'="([^"]*)"', attrs) or [None, None])[1] def fit_overflow(svg, pad=5, min_size=7.0): """Return svg with over-wide text runs shrunk to fit their box/canvas.""" mvb = _VIEWBOX.search(svg) if mvb: vb_x, _vb_y, vb_w, _vb_h = (float(g) for g in mvb.groups()) else: vb_x, vb_w = 0.0, 1e9 vb_right = vb_x + vb_w rects = [tuple(float(g) for g in m.groups()) for m in _RECT_TAG.finditer(svg)] def repl(m): attrs, content = m.group(1), m.group(2) text = html.unescape(re.sub(r'<[^>]+>', '', content)) if not text.strip(): return m.group(0) try: x = float(_ATTR(attrs, 'x')) y = float(_ATTR(attrs, 'y')) fs = float(_ATTR(attrs, 'font-size')) except (TypeError, ValueError): return m.group(0) anchor = _ATTR(attrs, 'text-anchor') or 'start' rtl = (_ATTR(attrs, 'direction') == 'rtl') fam = _ATTR(attrs, 'font-family') or '' bold = (_ATTR(attrs, 'font-weight') == 'bold') mono = 'Courier' in fam floor = 6.0 if mono else min_size # dense code insets tolerate a smaller floor w = _text_width(text, fs, bold, mono) if rtl and anchor == 'start': lo, hi = x - w, x elif rtl and anchor == 'end': lo, hi = x, x + w else: lo, hi = _extent(x, w, anchor) # available width from the canvas if rtl and anchor == 'start': avail = x - vb_x elif rtl and anchor == 'end': avail = vb_right - x elif anchor == 'start': avail = vb_right - x elif anchor == 'end': avail = x - vb_x else: avail = 2 * min(x - vb_x, vb_right - x) # available width from the smallest containing rect cont = [r for r in rects if r[0] - 1 <= x <= r[0] + r[2] + 1 and r[1] - 1 <= y <= r[1] + r[3] + 1] if cont: rx, _ry, rw, _rh = min(cont, key=lambda r: r[2] * r[3]) left, right = rx + pad, rx + rw - pad if anchor == 'start': box_avail = right - x elif anchor == 'end': box_avail = x - left else: box_avail = 2 * min(x - left, right - x) avail = min(avail, box_avail) if w <= avail + 1 or avail <= 0: if avail <= 0: new_fs = floor else: return m.group(0) else: # Floor (not round) to 0.5px so the shrunk text never re-overflows. new_fs = max(floor, math.floor(fs * avail / w * 2) / 2) if new_fs >= fs: return m.group(0) new_attrs = re.sub(r'font-size="[^"]*"', f'font-size="{new_fs:g}"', attrs) return f'{content}' return _TEXT_TAG.sub(repl, svg) def _marker_def(): return ( '' '' f'' '' '' f'' '' '' ) class SVG: """SVG diagram builder.""" def __init__(self, width, height): self.width = width self.height = height self.elems = [] def rect(self, x, y, w, h, fill='light', stroke='border', rx=CORNER_R, dash=False): c_fill = COLORS.get(fill, fill) c_stroke = COLORS.get(stroke, stroke) d = ' stroke-dasharray="8,4"' if dash else '' self.elems.append( f'' ) def box(self, x, y, w, h, label, fill='light', sublabel=None, bold=False, font_size=FS_BODY): self.rect(x, y, w, h, fill=fill) pad = 10 avail_w = max(8, w - 2 * pad) main_raw = str(label).split('\n') sub_raw = str(sublabel).split('\n') if sublabel else [] # Shrink the font until wrapped text fits both the width and the height # of the box (English translations are wider than the original CJK). fs = font_size while True: sub_fs = max(fs - 2, 8) main_lines = [] for ln in main_raw: main_lines += _wrap_line(ln, avail_w, fs, bold) sub_lines = [] for ln in sub_raw: sub_lines += _wrap_line(ln, avail_w, sub_fs, False) line_h = fs * 1.3 total_h = (len(main_lines) + len(sub_lines)) * line_h widest = max( [_text_width(l, fs, bold) for l in main_lines] + [_text_width(l, sub_fs, False) for l in sub_lines] + [0] ) if (total_h <= h - 6 and widest <= avail_w) or fs <= 9: break fs -= 0.5 rendered = [(l, fs, bold, 'text') for l in main_lines] \ + [(l, sub_fs, False, 'text_light') for l in sub_lines] line_h = fs * 1.3 n = len(rendered) start_y = y + h / 2 - (n - 1) * line_h / 2 for i, (line, lfs, lbold, lfill) in enumerate(rendered): ly = start_y + i * line_h fw = 'bold' if lbold else 'normal' self.elems.append( f'{_escape(line)}' ) def text(self, x, y, content, size=FS_BODY, bold=False, anchor='middle', fill='text', baseline='central', max_width=None): # Skip in-figure titles per academic convention (titles belong in body text). # Drop any FS_TITLE-sized phrase anywhere in the figure; keep short symbols # (VS / → / + etc.) which also happen to use the title size as diagram content. if OMIT_TITLE and size == FS_TITLE and len(str(content).strip()) >= TITLE_MIN_LEN: return if max_width: size = _fit_font(content, max_width, size, bold) c = COLORS.get(fill, fill) fw = 'bold' if bold else 'normal' self.elems.append( f'' f'{_escape(content)}' ) def mono(self, x, y, content, size=FS_SMALL, anchor='start', fill='text', max_width=None): """Monospace text for code snippets.""" if max_width: size = _fit_font(content, max_width, size, mono=True) c = COLORS.get(fill, fill) self.elems.append( f'' f'{_escape(content)}' ) def code_block(self, x, y, w, lines, font_size=FS_SMALL, line_h=None): """Render a block of monospace code lines with background.""" avail = w - 20 fs = font_size while fs > 6 and max((_text_width(l, fs, mono=True) for l in lines), default=0) > avail: fs -= 0.5 if line_h is None: line_h = fs * 1.5 h = len(lines) * line_h + 12 self.rect(x, y, w, h, fill='code_bg', stroke='dark', rx=4) for i, line in enumerate(lines): ly = y + 10 + i * line_h + line_h / 2 self.mono(x + 10, ly, line, size=fs) return h def multiline_text(self, x, y, lines, size=FS_BODY, anchor='middle', fill='text', line_h=None, bold=False, max_width=None): """Render multiple lines of text.""" if line_h is None: line_h = size * 1.4 for i, line in enumerate(lines): ly = y + i * line_h self.text(x, ly, line, size=size, anchor=anchor, fill=fill, bold=bold, max_width=max_width) def text_block(self, x, top_y, max_w, items, size=FS_SMALL, min_size=8, line_gap=1.2, bold=False, anchor='middle', mono=False): """Word-wrap one or more captions to max_w at a single uniform font size (shrinking only if an unbreakable token is too wide), then stack them downward from top_y. Prevents the "long line shrunk tiny while the short line stays large" look that plain text()+fit_overflow produces. items: list of strings or (text, fill) tuples. Returns the bottom y. """ norm = [t if isinstance(t, tuple) else (t, 'text') for t in items] def wrap_all(f): out = [] for t, fl in norm: for ln in _wrap_line(str(t), max_w, f, bold, mono): out.append((ln, fl)) return out fs = size wrapped = wrap_all(fs) while fs > min_size and max((_text_width(l, fs, bold, mono) for l, _ in wrapped), default=0) > max_w: fs -= 0.5 wrapped = wrap_all(fs) lh = fs * line_gap y = top_y + fs * 0.85 for ln, fl in wrapped: if mono: self.mono(x, y, ln, size=fs, anchor=anchor, fill=fl) else: self.text(x, y, ln, size=fs, anchor=anchor, fill=fl, bold=bold) y += lh return y - lh + fs * 0.15 def arrow(self, x1, y1, x2, y2, label=None, dash=False, color='border'): c = COLORS.get(color, color) d = ' stroke-dasharray="8,4"' if dash else '' mk = 'ah-light' if color in ('dark', COLORS['dark']) else 'ah' self.elems.append( f'' ) if label: mx, my = (x1 + x2) / 2, (y1 + y2) / 2 self.elems.append( f'{_escape(label)}' ) def arrow_curved(self, x1, y1, x2, y2, curve=30, label=None, dash=False, color='border'): """Draw a curved arrow using a quadratic bezier.""" c = COLORS.get(color, color) d = ' stroke-dasharray="8,4"' if dash else '' mk = 'ah-light' if color in ('dark', COLORS['dark']) else 'ah' mx, my = (x1 + x2) / 2, (y1 + y2) / 2 dx, dy = x2 - x1, y2 - y1 dist = math.sqrt(dx * dx + dy * dy) if dist > 0: nx, ny = -dy / dist * curve, dx / dist * curve else: nx, ny = 0, -curve cx, cy = mx + nx, my + ny self.elems.append( f'' ) if label: lx, ly = (x1 + 2 * cx + x2) / 4, (y1 + 2 * cy + y2) / 4 self.text(lx, ly - 10, label, size=FS_LABEL, fill='text_light') def line(self, x1, y1, x2, y2, dash=False, color='border'): c = COLORS.get(color, color) d = ' stroke-dasharray="8,4"' if dash else '' self.elems.append( f'' ) def circle(self, cx, cy, r, fill='light', label=None, font_size=FS_SMALL): c = COLORS.get(fill, fill) self.elems.append( f'' ) if label: fs = _fit_font(label, r * 1.7, font_size) self.elems.append( f'' f'{_escape(label)}' ) def diamond(self, cx, cy, w, h, fill='light', label=None, font_size=FS_SMALL): c = COLORS.get(fill, fill) pts = f'{cx},{cy - h / 2} {cx + w / 2},{cy} {cx},{cy + h / 2} {cx - w / 2},{cy}' self.elems.append( f'' ) if label: fs = _fit_font(label, w * 0.6, font_size) self.elems.append( f'' f'{_escape(label)}' ) def brace_right(self, x, y1, y2, label=None): my = (y1 + y2) / 2 d = (f'M {x},{y1} C {x + 20},{y1} {x + 20},{my - 5} {x + 25},{my} ' f'C {x + 20},{my + 5} {x + 20},{y2} {x},{y2}') self.elems.append( f'' ) if label: self.text(x + 35, my, label, size=FS_SMALL, anchor='start') def group_box(self, x, y, w, h, label, fill='white'): """A dashed group boundary with a label at top-left.""" self.rect(x, y, w, h, fill=fill, rx=8, dash=True) self.text(x + 12, y + 18, label, size=FS_SMALL, bold=True, fill='text_light', anchor='start', max_width=w - 24) def badge(self, x, y, w, h, label, fill='dark', font_size=FS_SMALL): """Small rounded badge/tag. Widens (keeping its center) to fit the label.""" need = _text_width(label, font_size, bold=True) + h + 8 if need > w: cx = x + w / 2 w = need x = cx - w / 2 self.rect(x, y, w, h, fill=fill, rx=h // 2) self.text(x + w / 2, y + h / 2, label, size=font_size, fill='white', bold=True) def render(self): if OMIT_TITLE: crop = TITLE_CROP_PX vb = f'0 {crop} {self.width} {self.height - crop}' h_attr = self.height - crop else: vb = f'0 0 {self.width} {self.height}' h_attr = self.height parts = [ f'', _marker_def(), ] parts.extend(self.elems) parts.append('') return fit_overflow('\n'.join(parts)) def save(self, path): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w', encoding='utf-8') as f: f.write(self.render()) def flow_lr(nodes, width=800, node_h=55, node_w=None, fills=None, spacing=25): """Left-to-right flow diagram: nodes connected by arrows.""" n = len(nodes) if node_w is None: node_w = min(150, (width - spacing * (n + 1)) // n) total_w = n * node_w + (n - 1) * spacing x_start = (width - total_w) / 2 height = node_h + 70 svg = SVG(width, height) y = (height - node_h) / 2 positions = [] for i, label in enumerate(nodes): x = x_start + i * (node_w + spacing) f = (fills[i] if fills else 'light') if fills and i < len(fills) else 'light' svg.box(x, y, node_w, node_h, label, fill=f) positions.append((x, y)) if i > 0: px = positions[i - 1][0] + node_w svg.arrow(px + 2, y + node_h / 2, x - 2, y + node_h / 2) return svg def flow_tb(nodes, width=350, node_h=55, node_w=240, fills=None, spacing=35, arrow_labels=None): """Top-to-bottom flow diagram.""" n = len(nodes) height = n * node_h + (n - 1) * spacing + 50 svg = SVG(width, height) x = (width - node_w) / 2 positions = [] for i, label in enumerate(nodes): y = 25 + i * (node_h + spacing) f = (fills[i] if fills else 'light') if fills and i < len(fills) else 'light' svg.box(x, y, node_w, node_h, label, fill=f) positions.append((x, y)) if i > 0: al = arrow_labels[i - 1] if arrow_labels and i - 1 < len(arrow_labels) else None svg.arrow(x + node_w / 2, positions[i - 1][1] + node_h + 2, x + node_w / 2, y - 2, label=al) return svg def tree_diagram(root, children, width=750, root_h=60, child_h=55, child_w=None, root_w=220): """Tree diagram: root node with children below.""" n = len(children) if child_w is None: child_w = min(170, (width - 20) // max(n, 1)) spacing = 20 total_cw = n * child_w + (n - 1) * spacing x_start = (width - total_cw) / 2 height = root_h + child_h + 120 svg = SVG(width, height) rx = (width - root_w) / 2 svg.box(rx, 20, root_w, root_h, root, fill='medium', bold=True) root_cx = width / 2 root_bot = 20 + root_h for i, label in enumerate(children): cx = x_start + i * (child_w + spacing) + child_w / 2 cy = root_bot + 55 svg.line(root_cx, root_bot, cx, cy) svg.box(x_start + i * (child_w + spacing), cy, child_w, child_h, label) return svg def layer_diagram(layers, width=600, layer_h=55, spacing=14): """Stacked horizontal layers (top = first layer).""" n = len(layers) lw = width - 80 height = n * layer_h + (n - 1) * spacing + 50 svg = SVG(width, height) x = 40 for i, (label, fill) in enumerate(layers): y = 25 + i * (layer_h + spacing) svg.box(x, y, lw, layer_h, label, fill=fill) return svg def comparison_lr(left_title, left_items, right_title, right_items, width=750, item_h=45): """Side-by-side comparison diagram.""" col_w = (width - 100) // 2 n = max(len(left_items), len(right_items)) height = 80 + n * (item_h + 10) + 25 svg = SVG(width, height) lx = 25 rx = width - col_w - 25 svg.box(lx, 20, col_w, 50, left_title, fill='medium', bold=True) svg.box(rx, 20, col_w, 50, right_title, fill='medium', bold=True) for i, label in enumerate(left_items): y = 85 + i * (item_h + 10) svg.box(lx, y, col_w, item_h, label, fill='light') for i, label in enumerate(right_items): y = 85 + i * (item_h + 10) svg.box(rx, y, col_w, item_h, label, fill='light') return svg def cycle_diagram(nodes, width=480, height=480, radius=160): """Circular cycle diagram with arrows between nodes.""" n = len(nodes) cx, cy = width / 2, height / 2 svg = SVG(width, height) node_w, node_h = 120, 50 positions = [] for i in range(n): angle = -math.pi / 2 + 2 * math.pi * i / n nx = cx + radius * math.cos(angle) ny = cy + radius * math.sin(angle) positions.append((nx, ny)) svg.box(nx - node_w / 2, ny - node_h / 2, node_w, node_h, nodes[i], fill='light', font_size=FS_SMALL) for i in range(n): j = (i + 1) % n x1, y1 = positions[i] x2, y2 = positions[j] dx, dy = x2 - x1, y2 - y1 dist = math.sqrt(dx * dx + dy * dy) ux, uy = dx / dist, dy / dist offset_start = max(node_w, node_h) / 2 + 5 offset_end = max(node_w, node_h) / 2 + 5 svg.arrow(x1 + ux * offset_start, y1 + uy * offset_start, x2 - ux * offset_end, y2 - uy * offset_end) return svg