|
| 1 | +# Copyright 2023 Adobe |
| 2 | +# All Rights Reserved. |
| 3 | + |
| 4 | +# NOTICE: Adobe permits you to use, modify, and distribute this file in |
| 5 | +# accordance with the terms of the Adobe license agreement accompanying |
| 6 | +# it. |
| 7 | + |
| 8 | +''' |
| 9 | +Creates character charts similar to the Unicode.org charts for The Unicode |
| 10 | +Standard, but using the supplied font (and only the characters present in the |
| 11 | +font). |
| 12 | +
|
| 13 | +Input: font file or folder containing font files |
| 14 | +
|
| 15 | +CLI Inputs: see help |
| 16 | +
|
| 17 | +''' |
| 18 | + |
| 19 | +import argparse |
| 20 | +from collections import defaultdict |
| 21 | +from functools import partial |
| 22 | +from math import floor, ceil |
| 23 | +from pathlib import Path |
| 24 | +import subprocess |
| 25 | +import sys |
| 26 | + |
| 27 | +import drawBot as db |
| 28 | +from fontTools.ttLib import TTFont |
| 29 | +import unicodedataplus |
| 30 | + |
| 31 | +from proofing_helpers.files import get_font_paths |
| 32 | +from proofing_helpers.fontSorter import sort_fonts |
| 33 | + |
| 34 | +IN_UI = 'drawBot.ui' in sys.modules |
| 35 | + |
| 36 | +if IN_UI: |
| 37 | + from vanilla.dialogs import getFileOrFolder # noqa: F401 |
| 38 | + |
| 39 | + |
| 40 | +def get_options(args=None, description=__doc__): |
| 41 | + parser = argparse.ArgumentParser( |
| 42 | + description=description, |
| 43 | + formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| 44 | + ) |
| 45 | + |
| 46 | + parser.add_argument( |
| 47 | + 'input_dir', |
| 48 | + action='store', |
| 49 | + metavar='FOLDER', |
| 50 | + help='folder to crawl') |
| 51 | + |
| 52 | + parser.add_argument( |
| 53 | + '-o', '--output_file_name', |
| 54 | + action='store', |
| 55 | + metavar='PDF', |
| 56 | + help='output file name') |
| 57 | + |
| 58 | + parser.add_argument( |
| 59 | + '--pagesize', |
| 60 | + choices=["A4", "Letter"], |
| 61 | + default="Letter", |
| 62 | + help="Desired page size") |
| 63 | + |
| 64 | + parser.add_argument( |
| 65 | + '-v', '--varfont_axes', |
| 66 | + type=_parse_vf_args, |
| 67 | + help="Variable font axis info e.g. 'wght:400,wdth:100'") |
| 68 | + |
| 69 | + parser.add_argument( |
| 70 | + '-s', '--size', |
| 71 | + type=int, |
| 72 | + default=CHART_FONT_SIZE, |
| 73 | + help="Chart font pointsize override value") |
| 74 | + |
| 75 | + return parser.parse_args(args) |
| 76 | + |
| 77 | + |
| 78 | +FONTS_FOLDER = Path(__file__).parent / "_fonts" |
| 79 | +BLOCK_TITLE_FONT = FONTS_FOLDER / "SourceSans3-Bold.otf" |
| 80 | +BLOCK_TITLE_SIZE = 20 |
| 81 | +BLOCK_FONTNAME_SIZE = 14 |
| 82 | + |
| 83 | +CHART_LABEL_FONT = FONTS_FOLDER / "SourceSans3-Regular.otf" |
| 84 | +CHART_LABEL_SIZE = 11 |
| 85 | + |
| 86 | +CHART_COL_WIDTH = 32 |
| 87 | +CHART_ROW_HEIGHT = 42 |
| 88 | +CHART_ROW_LABEL_Y_OFFSET = 6 |
| 89 | +CHART_COL_LABEL_Y_OFFSET = -3 |
| 90 | +CHART_FONT_SIZE = 24 |
| 91 | +CHART_FONT_BL = CHART_ROW_HEIGHT * 0.52 |
| 92 | +BLOCK_FONTNAME_Y_OFFSET = 67 |
| 93 | + |
| 94 | +CODE_LABEL_FONT = FONTS_FOLDER / "SourceSans3-Light.otf" |
| 95 | +CODE_LABEL_SIZE = 7 |
| 96 | +CODE_LABEL_Y_OFFSET = 11 |
| 97 | + |
| 98 | +# line thicknesses |
| 99 | +THICK = 1.5 |
| 100 | +THIN = 0.1 |
| 101 | + |
| 102 | + |
| 103 | +def _parse_vf_args(axisstr): |
| 104 | + vffunc = partial(db.fontVariations) |
| 105 | + for axs in axisstr.split(","): |
| 106 | + ax, axv = axs.split(':') |
| 107 | + vffunc.keywords[ax] = float(axv) |
| 108 | + |
| 109 | + return vffunc |
| 110 | + |
| 111 | + |
| 112 | +def get_uni_blocks(): |
| 113 | + all_ranges = defaultdict(int) |
| 114 | + for u in range(0, 0x110000): |
| 115 | + uc = chr(u) |
| 116 | + gc = unicodedataplus.category(uc) |
| 117 | + if gc in ('Cc', 'Cn'): # exclude controls and non-characters |
| 118 | + continue |
| 119 | + block = unicodedataplus.block(uc) |
| 120 | + all_ranges[block] += 1 |
| 121 | + |
| 122 | + return all_ranges |
| 123 | + |
| 124 | + |
| 125 | +def draw_gauge(x, y, pct, w=25, h=12): |
| 126 | + with db.savedState(): |
| 127 | + db.linearGradient( |
| 128 | + startPoint=(x, y), |
| 129 | + endPoint=(x + w, y), |
| 130 | + colors=[[.9, .9, .9], [0, 0, 0]], |
| 131 | + locations=[0, 0.75]) |
| 132 | + db.rect(x, y, w, h) |
| 133 | + db.strokeWidth(0) |
| 134 | + db.fill(1) |
| 135 | + wpct = (pct / 100) * w |
| 136 | + db.rect(x + wpct, y, w - wpct, h) |
| 137 | + db.stroke(0) |
| 138 | + db.strokeWidth(0.3) |
| 139 | + db.fill(1, alpha=0) |
| 140 | + db.rect(x, y, w, h) |
| 141 | + |
| 142 | + |
| 143 | +def make_chart_doc(font_file, args): |
| 144 | + db.newDrawing() |
| 145 | + |
| 146 | + set_vf = args.varfont_axes or (lambda: None) |
| 147 | + in_font = TTFont(font_file) |
| 148 | + fontname = in_font['name'].getDebugName(4) |
| 149 | + umap = in_font['cmap'].getBestCmap() |
| 150 | + umapset = set(umap) |
| 151 | + uni_blocks = get_uni_blocks() |
| 152 | + |
| 153 | + # calculate textBox first baseline offset |
| 154 | + db.font(font_file, args.size) |
| 155 | + bls = db.textBoxBaselines(" ", (0, 0, CHART_FONT_SIZE * 2, CHART_FONT_SIZE * 2)) |
| 156 | + if len(bls) == 0: |
| 157 | + print(f"Unable to fit font; re-try using the --size option with value < {args.size}.") |
| 158 | + sys.exit(1) |
| 159 | + tbfbo = 0 - bls[0][1] |
| 160 | + |
| 161 | + NSMARKBASE = db.FormattedString( |
| 162 | + chr(0x25CC), |
| 163 | + font=font_file, |
| 164 | + fallbackFont=FONTS_FOLDER / "SourceCodePro-Regular.otf", |
| 165 | + fontSize=args.size, |
| 166 | + align="center", |
| 167 | + fill=(0.75), |
| 168 | + ) |
| 169 | + |
| 170 | + if 0x25CC in umap: |
| 171 | + NSMARKBASE.font = font_file |
| 172 | + |
| 173 | + font_blocks = defaultdict(set) |
| 174 | + |
| 175 | + for u in umap: |
| 176 | + cu = chr(u) |
| 177 | + gc = unicodedataplus.category(cu) |
| 178 | + if gc in ('Cc', 'Cn'): |
| 179 | + continue |
| 180 | + bk = unicodedataplus.block(cu) |
| 181 | + if bk != "No_Block": |
| 182 | + font_blocks[bk].add(u) |
| 183 | + |
| 184 | + # make index pages -- 64 blocks per page |
| 185 | + for ipg in range(ceil(len(font_blocks)/64)): |
| 186 | + db.newPage(args.pagesize) |
| 187 | + db.linkDestination(f"index{ipg}", (0, db.height())) |
| 188 | + db.fill(0) |
| 189 | + db.font(BLOCK_TITLE_FONT, 20) |
| 190 | + db.textBox(f'{fontname}', |
| 191 | + (0, db.height() - 60, db.width(), 28), |
| 192 | + align="center") |
| 193 | + |
| 194 | + # go through the blocks, break into <= 16 col pages |
| 195 | + for _bki, (blockname, blockcodes) in enumerate(font_blocks.items()): |
| 196 | + blockmin = min(blockcodes) |
| 197 | + blockmax = max(blockcodes) |
| 198 | + block_nmin = (blockmin // 16) * 16 |
| 199 | + block_nmax = ((blockmax // 16) * 16) + 15 |
| 200 | + |
| 201 | + for _bpi, blockpagemin in enumerate(range(block_nmin, block_nmax, 16 * 16)): |
| 202 | + # first page of block, make index entry |
| 203 | + pct = len(font_blocks[blockname]) / uni_blocks[blockname] * 100 |
| 204 | + if _bpi == 0: |
| 205 | + ipi = floor(_bki / 64) # which index page |
| 206 | + with db.pages()[ipi]: |
| 207 | + db.font(CODE_LABEL_FONT, 13) |
| 208 | + tc = floor((_bki % 64) / 32) |
| 209 | + tr = _bki % 32 |
| 210 | + cw = db.width() / 2 |
| 211 | + db.text( |
| 212 | + blockname, |
| 213 | + (20 + (tc * cw) + 30, db.height() - (tr * 20) - 80)) |
| 214 | + db.linkRect( |
| 215 | + blockname, |
| 216 | + (20 + (tc * cw), db.height() - (tr * 20) - 81, cw, 16)) |
| 217 | + draw_gauge(20 + (tc * cw), db.height() - (tr * 20) - 82, pct) |
| 218 | + |
| 219 | + blockpagemax = min(block_nmax, blockpagemin + 255) |
| 220 | + |
| 221 | + # if page would be empty, skip |
| 222 | + pageset = set(range(blockpagemin, blockpagemin + 256)) |
| 223 | + if not pageset.intersection(umapset): |
| 224 | + continue |
| 225 | + |
| 226 | + db.newPage(args.pagesize) |
| 227 | + db.font(BLOCK_TITLE_FONT, BLOCK_TITLE_SIZE) |
| 228 | + db.textBox(f'{blockname} (U+{blockpagemin:04X}-{blockpagemax:04X})', |
| 229 | + (0, db.height() - 60, db.width(), 36), |
| 230 | + align="center") |
| 231 | + if _bpi == 0: |
| 232 | + db.linkDestination(blockname, (0, db.height())) |
| 233 | + |
| 234 | + db.font(CHART_LABEL_FONT, BLOCK_FONTNAME_SIZE) |
| 235 | + db.textBox(f'{fontname}', |
| 236 | + (0, db.height() - BLOCK_FONTNAME_Y_OFFSET, db.width(), 20), |
| 237 | + align="center") |
| 238 | + db.linkRect("index0", |
| 239 | + (0, db.height() - BLOCK_FONTNAME_Y_OFFSET, db.width(), 18)) |
| 240 | + |
| 241 | + npagecols = min(ceil((block_nmax - blockpagemin) / 16), 16) |
| 242 | + chart_width = npagecols * CHART_COL_WIDTH |
| 243 | + chart_left = (db.width() / 2) - (chart_width / 2) |
| 244 | + db.stroke(0) |
| 245 | + |
| 246 | + CHART_BOX_TOP = db.height() - 80 |
| 247 | + CHART_BOX_BOT = CHART_BOX_TOP - (CHART_ROW_HEIGHT * 16) |
| 248 | + xpos = 0 |
| 249 | + |
| 250 | + for col in range(npagecols): |
| 251 | + db.lineCap("square") |
| 252 | + db.strokeWidth(THICK if col == 0 else THIN) |
| 253 | + xpos = chart_left + col * CHART_COL_WIDTH |
| 254 | + db.line((xpos, CHART_BOX_TOP), (xpos, CHART_BOX_BOT)) |
| 255 | + colheader = (blockpagemin + (16 * col)) // 16 |
| 256 | + db.strokeWidth(0) |
| 257 | + db.font(CHART_LABEL_FONT, CHART_LABEL_SIZE) |
| 258 | + # top column label |
| 259 | + ypos = CHART_BOX_TOP |
| 260 | + db.textBox(f'{colheader:03X}', |
| 261 | + (xpos, ypos + CHART_COL_LABEL_Y_OFFSET, CHART_COL_WIDTH, 16), |
| 262 | + align="center") |
| 263 | + |
| 264 | + # the actual font characters |
| 265 | + db.strokeWidth(THICK) |
| 266 | + db.line((chart_left + 1, ypos), |
| 267 | + (chart_left + chart_width, ypos)) |
| 268 | + ypos -= CHART_ROW_HEIGHT |
| 269 | + db.font(CHART_LABEL_FONT, CHART_LABEL_SIZE) |
| 270 | + set_vf() |
| 271 | + for uc in range(colheader * 16, (colheader * 16) + 16): |
| 272 | + if uc // 16 == colheader: |
| 273 | + db.strokeWidth(THICK if uc % 16 == 15 else THIN) |
| 274 | + db.line((chart_left, ypos), (chart_left + chart_width - 1, ypos)) |
| 275 | + db.strokeWidth(0) |
| 276 | + db.font(CHART_LABEL_FONT, CHART_LABEL_SIZE) |
| 277 | + # left-side row label |
| 278 | + db.textBox(f'{uc % 16:X}', |
| 279 | + (chart_left - 12, ypos + CHART_ROW_LABEL_Y_OFFSET, 8, 20), |
| 280 | + align="right") |
| 281 | + if uc in umap: |
| 282 | + # code label |
| 283 | + db.strokeWidth(0) |
| 284 | + db.font(CODE_LABEL_FONT, CODE_LABEL_SIZE) |
| 285 | + db.textBox(f'{uc:04X}', |
| 286 | + (xpos, ypos - CODE_LABEL_Y_OFFSET, CHART_COL_WIDTH - 2, 20), |
| 287 | + align="center") |
| 288 | + ypos -= CHART_ROW_HEIGHT |
| 289 | + |
| 290 | + # draw font glyphs all in one go |
| 291 | + ypos = CHART_BOX_TOP - CHART_ROW_HEIGHT |
| 292 | + db.strokeWidth(0) |
| 293 | + db.font(font_file, CHART_FONT_SIZE) |
| 294 | + for uc in range(colheader * 16, (colheader * 16) + 16): |
| 295 | + ucc = chr(uc) |
| 296 | + gc = unicodedataplus.category(ucc) |
| 297 | + |
| 298 | + if gc == "Cn": |
| 299 | + # for Unassigned characters (gc=Cn), fill gray |
| 300 | + with db.savedState(): |
| 301 | + db.fill(0, alpha=.25) |
| 302 | + db.rect(xpos, ypos, CHART_COL_WIDTH, CHART_ROW_HEIGHT) |
| 303 | + |
| 304 | + if uc in umap: |
| 305 | + bl = ypos + CHART_FONT_BL + tbfbo |
| 306 | + txt = db.FormattedString( |
| 307 | + chr(uc), |
| 308 | + font=font_file, |
| 309 | + fontSize=args.size, |
| 310 | + align="center", |
| 311 | + fontVariations=set_vf()) |
| 312 | + sc = unicodedataplus.script(chr(uc)) |
| 313 | + if gc == "Mn" and sc == "Inherited": |
| 314 | + txt = NSMARKBASE + txt # dotted circle base |
| 315 | + db.textBox(txt, |
| 316 | + (xpos, bl, CHART_COL_WIDTH, CHART_ROW_HEIGHT), |
| 317 | + align="center") |
| 318 | + ypos -= CHART_ROW_HEIGHT |
| 319 | + |
| 320 | + # right line |
| 321 | + db.strokeWidth(THICK if blockpagemax // 16 == block_nmax // 16 else THIN) |
| 322 | + db.line((xpos + CHART_COL_WIDTH, CHART_BOX_TOP), |
| 323 | + (xpos + CHART_COL_WIDTH, CHART_BOX_BOT)) |
| 324 | + |
| 325 | + |
| 326 | +def save_document(output_path): |
| 327 | + db.saveImage(output_path) |
| 328 | + print('saved PDF to', output_path) |
| 329 | + subprocess.call(['open', output_path]) |
| 330 | + db.endDrawing() |
| 331 | + |
| 332 | + |
| 333 | +if __name__ == '__main__': |
| 334 | + if IN_UI: |
| 335 | + file_or_folder = getFileOrFolder(allowsMultipleSelection=False) |
| 336 | + input_dir = str(file_or_folder[0]) |
| 337 | + args = get_options([input_dir]) |
| 338 | + |
| 339 | + else: |
| 340 | + args = get_options() |
| 341 | + |
| 342 | + font_paths = get_font_paths(args.input_dir) |
| 343 | + sorted_font_paths = sort_fonts(font_paths) |
| 344 | + |
| 345 | + for font_path in sorted_font_paths: |
| 346 | + make_chart_doc(font_path, args) |
| 347 | + |
| 348 | + if not IN_UI: |
| 349 | + out_name = args.output_file_name or font_path.parent / f'{font_path.stem}_chart.pdf' |
| 350 | + save_document(out_name) |
0 commit comments