Quality of curve not preserving in output font using fontTools and PyMuPDF in Python

2 days ago 8
ARTICLE AD BOX

I am making a software where it will convert grid-based glyph layout AI (Adobe Illustrator) file to font file with help of ChatGPT. Now it is almost working fine, just a problem, quality of curve is downgrading. I want to preserve quality of curve. I tried to fix it but I can not figured out the problem.

import math import fitz # PyMuPDF from fontTools.ttLib import TTFont from fontTools.pens.t2CharStringPen import T2CharStringPen # --------------------------- # CONFIG # --------------------------- AI_OR_PDF_PATH = "input.pdf" GRID_N = 15 TILE_SIZE = 700 origin_x = 0 origin_y = 0 INPUT_FONT = "input.otf" # must be CFF/OTF for cubic fidelity OUTPUT_FONT = "output.otf" GRID_MAP = { (0, 0): "অ", (0, 1): "আ", (0, 2): "ই", (0, 3): "ঈ", (0, 4): "উ", (0, 5): "ঊ", } FLIP_Y = True # PDF Y goes down; fonts Y goes up SCALE = 1.0 # keep 1.0 unless your PDF is tiny/huge # --------------------------- # PART A: PDF DRAWINGS -> TILE FILTER # --------------------------- def drawings_for_tile(page, tile_rect): """ Return list of drawing dicts that overlap tile_rect AND are actually painted (not just clip paths). Also drop suspiciously huge leftovers. """ kept = [] for d in page.get_drawings(): r = fitz.Rect(d["rect"]) if not r.intersects(tile_rect): continue # drop huge leftovers (full-page junk that intersects tile) if r.width > tile_rect.width * 1.2 or r.height > tile_rect.height * 1.2: continue fill = d.get("fill") stroke = d.get("color") fo = d.get("fill_opacity", 1.0) so = d.get("stroke_opacity", 1.0) # skip invisible / clip-only paths if (fill is None or fo == 0) and (stroke is None or so == 0): continue kept.append(d) return kept # --------------------------- # PART B: DRAWING -> PEN (ROBUST CFF CUBICS) # --------------------------- def draw_drawing_to_pen(drawing, pen, tile_rect, flip_y=False, scale=1.0): """ Robust PDF drawing -> CFF pen. - Ensures contours are started before curves. - Skips malformed segments safely. - Preserves cubics. """ items = drawing["items"] H = tile_rect.height cur = None start_pt = None contour_open = False def is_finite_pt(p): return ( p is not None and len(p) == 2 and math.isfinite(p[0]) and math.isfinite(p[1]) ) def loc(p): x = (p.x - tile_rect.x0) * scale y = (p.y - tile_rect.y0) * scale if flip_y: y = (H * scale) - y return (x, y) def ensure_contour_started(p): """If no active contour, start one at p.""" nonlocal contour_open, cur, start_pt if not contour_open: pen.moveTo(p) start_pt = p cur = p contour_open = True def close_if_open(): nonlocal contour_open, cur, start_pt if contour_open: pen.closePath() contour_open = False cur = start_pt for it in items: op = it[0] if op == "m": # moveTo (starts new contour) close_if_open() p = loc(it[1]) if not is_finite_pt(p): continue pen.moveTo(p) cur = p start_pt = p contour_open = True elif op == "l": # lineTo p = loc(it[1]) if not is_finite_pt(p): continue ensure_contour_started(p) pen.lineTo(p) cur = p elif op == "c": # cubic curveTo if len(it) < 4: continue c1 = loc(it[1]) c2 = loc(it[2]) p = loc(it[3]) if not (is_finite_pt(c1) and is_finite_pt(c2) and is_finite_pt(p)): continue ensure_contour_started(p) pen.curveTo(c1, c2, p) cur = p contour_open = True elif op == "v": # cubic shorthand: first ctrl = current point if cur is None or len(it) < 3: continue c1 = cur c2 = loc(it[1]) p = loc(it[2]) if not (is_finite_pt(c2) and is_finite_pt(p)): continue ensure_contour_started(p) pen.curveTo(c1, c2, p) cur = p contour_open = True elif op == "y": # cubic shorthand: second ctrl = end point if len(it) < 3: continue c1 = loc(it[1]) p = loc(it[2]) c2 = p if not (is_finite_pt(c1) and is_finite_pt(p)): continue ensure_contour_started(p) pen.curveTo(c1, c2, p) cur = p contour_open = True elif op in ("h", "z"): # closePath close_if_open() elif op == "re": # rectangle r = it[1] x0 = (r.x0 - tile_rect.x0) * scale y0 = (r.y0 - tile_rect.y0) * scale x1 = (r.x1 - tile_rect.x0) * scale y1 = (r.y1 - tile_rect.y0) * scale if flip_y: y0, y1 = (H * scale) - y0, (H * scale) - y1 close_if_open() p0 = (x0, y0) p1 = (x1, y0) p2 = (x1, y1) p3 = (x0, y1) if not all(is_finite_pt(p) for p in (p0, p1, p2, p3)): continue pen.moveTo(p0) pen.lineTo(p1) pen.lineTo(p2) pen.lineTo(p3) pen.closePath() contour_open = False cur = p0 start_pt = p0 # ignore unknown ops safely close_if_open() # --------------------------- # TILE -> CFF charstring (OTF CFF) # --------------------------- def tile_to_cff_charstring_from_pdf(page, tile_rect, font, template_cs): """ Convert tile drawings to a CFF CharString with cubic curves preserved. Also attaches CFF private/subrs metadata so fontTools can compile. """ glyph_set = font.getGlyphSet() drawings = drawings_for_tile(page, tile_rect) if not drawings: raise ValueError("No usable drawings in tile") pen = T2CharStringPen(width=0, glyphSet=glyph_set) for d in drawings: draw_drawing_to_pen(d, pen, tile_rect, flip_y=FLIP_Y, scale=SCALE) cs = pen.getCharString() # ---- IMPORTANT: attach CFF metadata to avoid save-time crashes ---- cs.private = template_cs.private cs.globalSubrs = template_cs.globalSubrs if hasattr(template_cs, "localSubrs"): cs.localSubrs = template_cs.localSubrs new_aw = int(round(tile_rect.width * SCALE * 1.1)) return cs, new_aw # --------------------------- # FONT HELPERS # --------------------------- def ensure_cff_font(font): if "CFF " not in font: raise RuntimeError("Input font is not CFF/OTF. Use a .otf with CFF outlines.") cff_top = font["CFF "].cff.topDictIndex[0] return cff_top, cff_top.CharStrings def add_to_glyph_order(font, glyph_name): order = list(font.getGlyphOrder()) if glyph_name not in order: order.append(glyph_name) font.setGlyphOrder(order) def update_cmap_all_unicode(font, cmap_dict): if "cmap" not in font: return for table in font["cmap"].tables: if table.isUnicode(): table.cmap.update(cmap_dict) def ensure_glyph_exists(font, charstrings, best_cmap, codepoint): """ Ensure glyph for codepoint exists in cmap + glyphOrder + charstrings. Returns glyph_name. """ glyph_name = best_cmap.get(codepoint) if glyph_name is not None: return glyph_name glyph_name = f"uni{codepoint:04X}" best_cmap[codepoint] = glyph_name add_to_glyph_order(font, glyph_name) # placeholder clone from .notdef charstrings[glyph_name] = charstrings[".notdef"] return glyph_name # --------------------------- # MAIN REPLACEMENT LOOP # --------------------------- def replace_glyphs_from_map(): font = TTFont(INPUT_FONT) best_cmap = font.getBestCmap() or {} cff_top, charstrings = ensure_cff_font(font) template_cs = charstrings[".notdef"] # open PDF once src_doc = fitz.open(AI_OR_PDF_PATH) page = src_doc[0] for (row, col), ch in GRID_MAP.items(): x0 = origin_x + col * TILE_SIZE y0 = origin_y + row * TILE_SIZE tile_rect = fitz.Rect(x0, y0, x0 + TILE_SIZE, y0 + TILE_SIZE) codepoint = ord(ch) glyph_name = ensure_glyph_exists(font, charstrings, best_cmap, codepoint) try: new_cs, new_aw = tile_to_cff_charstring_from_pdf( page, tile_rect, font, template_cs ) charstrings[glyph_name] = new_cs except Exception as e: print(f"[ERROR] r{row} c{col} -> {ch} failed: {e}") continue if "hmtx" in font: hmtx = font["hmtx"].metrics old_aw, old_lsb = hmtx.get(glyph_name, (new_aw, 0)) hmtx[glyph_name] = (new_aw, old_lsb) print(f"✓ Replaced '{ch}' ({glyph_name}) from tile r{row:02d}, c{col:02d}") src_doc.close() update_cmap_all_unicode(font, best_cmap) font.save(OUTPUT_FONT) print(f"\n✓ Done! Saved modified font to {OUTPUT_FONT}") # --------------------------- # RUN # --------------------------- if __name__ == "__main__": replace_glyphs_from_map()
Read Entire Article