Coverage for /usr/lib/python3/dist-packages/matplotlib/dviread.py: 28%

546 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1""" 

2A module for reading dvi files output by TeX. Several limitations make 

3this not (currently) useful as a general-purpose dvi preprocessor, but 

4it is currently used by the pdf backend for processing usetex text. 

5 

6Interface:: 

7 

8 with Dvi(filename, 72) as dvi: 

9 # iterate over pages: 

10 for page in dvi: 

11 w, h, d = page.width, page.height, page.descent 

12 for x, y, font, glyph, width in page.text: 

13 fontname = font.texname 

14 pointsize = font.size 

15 ... 

16 for x, y, height, width in page.boxes: 

17 ... 

18""" 

19 

20from collections import namedtuple 

21import enum 

22from functools import lru_cache, partial, wraps 

23import logging 

24import os 

25from pathlib import Path 

26import re 

27import struct 

28import subprocess 

29import sys 

30 

31import numpy as np 

32 

33from matplotlib import _api, cbook 

34 

35_log = logging.getLogger(__name__) 

36 

37# Many dvi related files are looked for by external processes, require 

38# additional parsing, and are used many times per rendering, which is why they 

39# are cached using lru_cache(). 

40 

41# Dvi is a bytecode format documented in 

42# https://ctan.org/pkg/dvitype 

43# https://texdoc.org/serve/dvitype.pdf/0 

44# 

45# The file consists of a preamble, some number of pages, a postamble, 

46# and a finale. Different opcodes are allowed in different contexts, 

47# so the Dvi object has a parser state: 

48# 

49# pre: expecting the preamble 

50# outer: between pages (followed by a page or the postamble, 

51# also e.g. font definitions are allowed) 

52# page: processing a page 

53# post_post: state after the postamble (our current implementation 

54# just stops reading) 

55# finale: the finale (unimplemented in our current implementation) 

56 

57_dvistate = enum.Enum('DviState', 'pre outer inpage post_post finale') 

58 

59# The marks on a page consist of text and boxes. A page also has dimensions. 

60Page = namedtuple('Page', 'text boxes height width descent') 

61Box = namedtuple('Box', 'x y height width') 

62 

63 

64# Also a namedtuple, for backcompat. 

65class Text(namedtuple('Text', 'x y font glyph width')): 

66 """ 

67 A glyph in the dvi file. 

68 

69 The *x* and *y* attributes directly position the glyph. The *font*, 

70 *glyph*, and *width* attributes are kept public for back-compatibility, 

71 but users wanting to draw the glyph themselves are encouraged to instead 

72 load the font specified by `font_path` at `font_size`, warp it with the 

73 effects specified by `font_effects`, and load the glyph specified by 

74 `glyph_name_or_index`. 

75 """ 

76 

77 def _get_pdftexmap_entry(self): 

78 return PsfontsMap(find_tex_file("pdftex.map"))[self.font.texname] 

79 

80 @property 

81 def font_path(self): 

82 """The `~pathlib.Path` to the font for this glyph.""" 

83 psfont = self._get_pdftexmap_entry() 

84 if psfont.filename is None: 

85 raise ValueError("No usable font file found for {} ({}); " 

86 "the font may lack a Type-1 version" 

87 .format(psfont.psname.decode("ascii"), 

88 psfont.texname.decode("ascii"))) 

89 return Path(psfont.filename) 

90 

91 @property 

92 def font_size(self): 

93 """The font size.""" 

94 return self.font.size 

95 

96 @property 

97 def font_effects(self): 

98 """ 

99 The "font effects" dict for this glyph. 

100 

101 This dict contains the values for this glyph of SlantFont and 

102 ExtendFont (if any), read off :file:`pdftex.map`. 

103 """ 

104 return self._get_pdftexmap_entry().effects 

105 

106 @property 

107 def glyph_name_or_index(self): 

108 """ 

109 Either the glyph name or the native charmap glyph index. 

110 

111 If :file:`pdftex.map` specifies an encoding for this glyph's font, that 

112 is a mapping of glyph indices to Adobe glyph names; use it to convert 

113 dvi indices to glyph names. Callers can then convert glyph names to 

114 glyph indices (with FT_Get_Name_Index/get_name_index), and load the 

115 glyph using FT_Load_Glyph/load_glyph. 

116 

117 If :file:`pdftex.map` specifies no encoding, the indices directly map 

118 to the font's "native" charmap; glyphs should directly load using 

119 FT_Load_Char/load_char after selecting the native charmap. 

120 """ 

121 entry = self._get_pdftexmap_entry() 

122 return (_parse_enc(entry.encoding)[self.glyph] 

123 if entry.encoding is not None else self.glyph) 

124 

125 

126# Opcode argument parsing 

127# 

128# Each of the following functions takes a Dvi object and delta, 

129# which is the difference between the opcode and the minimum opcode 

130# with the same meaning. Dvi opcodes often encode the number of 

131# argument bytes in this delta. 

132 

133def _arg_raw(dvi, delta): 

134 """Return *delta* without reading anything more from the dvi file.""" 

135 return delta 

136 

137 

138def _arg(nbytes, signed, dvi, _): 

139 """ 

140 Read *nbytes* bytes, returning the bytes interpreted as a signed integer 

141 if *signed* is true, unsigned otherwise. 

142 """ 

143 return dvi._arg(nbytes, signed) 

144 

145 

146def _arg_slen(dvi, delta): 

147 """ 

148 Read *delta* bytes, returning None if *delta* is zero, and the bytes 

149 interpreted as a signed integer otherwise. 

150 """ 

151 if delta == 0: 

152 return None 

153 return dvi._arg(delta, True) 

154 

155 

156def _arg_slen1(dvi, delta): 

157 """ 

158 Read *delta*+1 bytes, returning the bytes interpreted as signed. 

159 """ 

160 return dvi._arg(delta + 1, True) 

161 

162 

163def _arg_ulen1(dvi, delta): 

164 """ 

165 Read *delta*+1 bytes, returning the bytes interpreted as unsigned. 

166 """ 

167 return dvi._arg(delta + 1, False) 

168 

169 

170def _arg_olen1(dvi, delta): 

171 """ 

172 Read *delta*+1 bytes, returning the bytes interpreted as 

173 unsigned integer for 0<=*delta*<3 and signed if *delta*==3. 

174 """ 

175 return dvi._arg(delta + 1, delta == 3) 

176 

177 

178_arg_mapping = dict(raw=_arg_raw, 

179 u1=partial(_arg, 1, False), 

180 u4=partial(_arg, 4, False), 

181 s4=partial(_arg, 4, True), 

182 slen=_arg_slen, 

183 olen1=_arg_olen1, 

184 slen1=_arg_slen1, 

185 ulen1=_arg_ulen1) 

186 

187 

188def _dispatch(table, min, max=None, state=None, args=('raw',)): 

189 """ 

190 Decorator for dispatch by opcode. Sets the values in *table* 

191 from *min* to *max* to this method, adds a check that the Dvi state 

192 matches *state* if not None, reads arguments from the file according 

193 to *args*. 

194 

195 Parameters 

196 ---------- 

197 table : dict[int, callable] 

198 The dispatch table to be filled in. 

199 

200 min, max : int 

201 Range of opcodes that calls the registered function; *max* defaults to 

202 *min*. 

203 

204 state : _dvistate, optional 

205 State of the Dvi object in which these opcodes are allowed. 

206 

207 args : list[str], default: ['raw'] 

208 Sequence of argument specifications: 

209 

210 - 'raw': opcode minus minimum 

211 - 'u1': read one unsigned byte 

212 - 'u4': read four bytes, treat as an unsigned number 

213 - 's4': read four bytes, treat as a signed number 

214 - 'slen': read (opcode - minimum) bytes, treat as signed 

215 - 'slen1': read (opcode - minimum + 1) bytes, treat as signed 

216 - 'ulen1': read (opcode - minimum + 1) bytes, treat as unsigned 

217 - 'olen1': read (opcode - minimum + 1) bytes, treat as unsigned 

218 if under four bytes, signed if four bytes 

219 """ 

220 def decorate(method): 

221 get_args = [_arg_mapping[x] for x in args] 

222 

223 @wraps(method) 

224 def wrapper(self, byte): 

225 if state is not None and self.state != state: 

226 raise ValueError("state precondition failed") 

227 return method(self, *[f(self, byte-min) for f in get_args]) 

228 if max is None: 

229 table[min] = wrapper 

230 else: 

231 for i in range(min, max+1): 

232 assert table[i] is None 

233 table[i] = wrapper 

234 return wrapper 

235 return decorate 

236 

237 

238class Dvi: 

239 """ 

240 A reader for a dvi ("device-independent") file, as produced by TeX. 

241 

242 The current implementation can only iterate through pages in order, 

243 and does not even attempt to verify the postamble. 

244 

245 This class can be used as a context manager to close the underlying 

246 file upon exit. Pages can be read via iteration. Here is an overly 

247 simple way to extract text without trying to detect whitespace:: 

248 

249 >>> with matplotlib.dviread.Dvi('input.dvi', 72) as dvi: 

250 ... for page in dvi: 

251 ... print(''.join(chr(t.glyph) for t in page.text)) 

252 """ 

253 # dispatch table 

254 _dtable = [None] * 256 

255 _dispatch = partial(_dispatch, _dtable) 

256 

257 def __init__(self, filename, dpi): 

258 """ 

259 Read the data from the file named *filename* and convert 

260 TeX's internal units to units of *dpi* per inch. 

261 *dpi* only sets the units and does not limit the resolution. 

262 Use None to return TeX's internal units. 

263 """ 

264 _log.debug('Dvi: %s', filename) 

265 self.file = open(filename, 'rb') 

266 self.dpi = dpi 

267 self.fonts = {} 

268 self.state = _dvistate.pre 

269 

270 baseline = _api.deprecated("3.5")(property(lambda self: None)) 

271 

272 def __enter__(self): 

273 """Context manager enter method, does nothing.""" 

274 return self 

275 

276 def __exit__(self, etype, evalue, etrace): 

277 """ 

278 Context manager exit method, closes the underlying file if it is open. 

279 """ 

280 self.close() 

281 

282 def __iter__(self): 

283 """ 

284 Iterate through the pages of the file. 

285 

286 Yields 

287 ------ 

288 Page 

289 Details of all the text and box objects on the page. 

290 The Page tuple contains lists of Text and Box tuples and 

291 the page dimensions, and the Text and Box tuples contain 

292 coordinates transformed into a standard Cartesian 

293 coordinate system at the dpi value given when initializing. 

294 The coordinates are floating point numbers, but otherwise 

295 precision is not lost and coordinate values are not clipped to 

296 integers. 

297 """ 

298 while self._read(): 

299 yield self._output() 

300 

301 def close(self): 

302 """Close the underlying file if it is open.""" 

303 if not self.file.closed: 

304 self.file.close() 

305 

306 def _output(self): 

307 """ 

308 Output the text and boxes belonging to the most recent page. 

309 page = dvi._output() 

310 """ 

311 minx, miny, maxx, maxy = np.inf, np.inf, -np.inf, -np.inf 

312 maxy_pure = -np.inf 

313 for elt in self.text + self.boxes: 

314 if isinstance(elt, Box): 

315 x, y, h, w = elt 

316 e = 0 # zero depth 

317 else: # glyph 

318 x, y, font, g, w = elt 

319 h, e = font._height_depth_of(g) 

320 minx = min(minx, x) 

321 miny = min(miny, y - h) 

322 maxx = max(maxx, x + w) 

323 maxy = max(maxy, y + e) 

324 maxy_pure = max(maxy_pure, y) 

325 if self._baseline_v is not None: 

326 maxy_pure = self._baseline_v # This should normally be the case. 

327 self._baseline_v = None 

328 

329 if not self.text and not self.boxes: # Avoid infs/nans from inf+/-inf. 

330 return Page(text=[], boxes=[], width=0, height=0, descent=0) 

331 

332 if self.dpi is None: 

333 # special case for ease of debugging: output raw dvi coordinates 

334 return Page(text=self.text, boxes=self.boxes, 

335 width=maxx-minx, height=maxy_pure-miny, 

336 descent=maxy-maxy_pure) 

337 

338 # convert from TeX's "scaled points" to dpi units 

339 d = self.dpi / (72.27 * 2**16) 

340 descent = (maxy - maxy_pure) * d 

341 

342 text = [Text((x-minx)*d, (maxy-y)*d - descent, f, g, w*d) 

343 for (x, y, f, g, w) in self.text] 

344 boxes = [Box((x-minx)*d, (maxy-y)*d - descent, h*d, w*d) 

345 for (x, y, h, w) in self.boxes] 

346 

347 return Page(text=text, boxes=boxes, width=(maxx-minx)*d, 

348 height=(maxy_pure-miny)*d, descent=descent) 

349 

350 def _read(self): 

351 """ 

352 Read one page from the file. Return True if successful, 

353 False if there were no more pages. 

354 """ 

355 # Pages appear to start with the sequence 

356 # bop (begin of page) 

357 # xxx comment 

358 # <push, ..., pop> # if using chemformula 

359 # down 

360 # push 

361 # down 

362 # <push, push, xxx, right, xxx, pop, pop> # if using xcolor 

363 # down 

364 # push 

365 # down (possibly multiple) 

366 # push <= here, v is the baseline position. 

367 # etc. 

368 # (dviasm is useful to explore this structure.) 

369 # Thus, we use the vertical position at the first time the stack depth 

370 # reaches 3, while at least three "downs" have been executed (excluding 

371 # those popped out (corresponding to the chemformula preamble)), as the 

372 # baseline (the "down" count is necessary to handle xcolor). 

373 down_stack = [0] 

374 self._baseline_v = None 

375 while True: 

376 byte = self.file.read(1)[0] 

377 self._dtable[byte](self, byte) 

378 name = self._dtable[byte].__name__ 

379 if name == "_push": 

380 down_stack.append(down_stack[-1]) 

381 elif name == "_pop": 

382 down_stack.pop() 

383 elif name == "_down": 

384 down_stack[-1] += 1 

385 if (self._baseline_v is None 

386 and len(getattr(self, "stack", [])) == 3 

387 and down_stack[-1] >= 4): 

388 self._baseline_v = self.v 

389 if byte == 140: # end of page 

390 return True 

391 if self.state is _dvistate.post_post: # end of file 

392 self.close() 

393 return False 

394 

395 def _arg(self, nbytes, signed=False): 

396 """ 

397 Read and return an integer argument *nbytes* long. 

398 Signedness is determined by the *signed* keyword. 

399 """ 

400 buf = self.file.read(nbytes) 

401 value = buf[0] 

402 if signed and value >= 0x80: 

403 value = value - 0x100 

404 for b in buf[1:]: 

405 value = 0x100*value + b 

406 return value 

407 

408 @_dispatch(min=0, max=127, state=_dvistate.inpage) 

409 def _set_char_immediate(self, char): 

410 self._put_char_real(char) 

411 self.h += self.fonts[self.f]._width_of(char) 

412 

413 @_dispatch(min=128, max=131, state=_dvistate.inpage, args=('olen1',)) 

414 def _set_char(self, char): 

415 self._put_char_real(char) 

416 self.h += self.fonts[self.f]._width_of(char) 

417 

418 @_dispatch(132, state=_dvistate.inpage, args=('s4', 's4')) 

419 def _set_rule(self, a, b): 

420 self._put_rule_real(a, b) 

421 self.h += b 

422 

423 @_dispatch(min=133, max=136, state=_dvistate.inpage, args=('olen1',)) 

424 def _put_char(self, char): 

425 self._put_char_real(char) 

426 

427 def _put_char_real(self, char): 

428 font = self.fonts[self.f] 

429 if font._vf is None: 

430 self.text.append(Text(self.h, self.v, font, char, 

431 font._width_of(char))) 

432 else: 

433 scale = font._scale 

434 for x, y, f, g, w in font._vf[char].text: 

435 newf = DviFont(scale=_mul2012(scale, f._scale), 

436 tfm=f._tfm, texname=f.texname, vf=f._vf) 

437 self.text.append(Text(self.h + _mul2012(x, scale), 

438 self.v + _mul2012(y, scale), 

439 newf, g, newf._width_of(g))) 

440 self.boxes.extend([Box(self.h + _mul2012(x, scale), 

441 self.v + _mul2012(y, scale), 

442 _mul2012(a, scale), _mul2012(b, scale)) 

443 for x, y, a, b in font._vf[char].boxes]) 

444 

445 @_dispatch(137, state=_dvistate.inpage, args=('s4', 's4')) 

446 def _put_rule(self, a, b): 

447 self._put_rule_real(a, b) 

448 

449 def _put_rule_real(self, a, b): 

450 if a > 0 and b > 0: 

451 self.boxes.append(Box(self.h, self.v, a, b)) 

452 

453 @_dispatch(138) 

454 def _nop(self, _): 

455 pass 

456 

457 @_dispatch(139, state=_dvistate.outer, args=('s4',)*11) 

458 def _bop(self, c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, p): 

459 self.state = _dvistate.inpage 

460 self.h, self.v, self.w, self.x, self.y, self.z = 0, 0, 0, 0, 0, 0 

461 self.stack = [] 

462 self.text = [] # list of Text objects 

463 self.boxes = [] # list of Box objects 

464 

465 @_dispatch(140, state=_dvistate.inpage) 

466 def _eop(self, _): 

467 self.state = _dvistate.outer 

468 del self.h, self.v, self.w, self.x, self.y, self.z, self.stack 

469 

470 @_dispatch(141, state=_dvistate.inpage) 

471 def _push(self, _): 

472 self.stack.append((self.h, self.v, self.w, self.x, self.y, self.z)) 

473 

474 @_dispatch(142, state=_dvistate.inpage) 

475 def _pop(self, _): 

476 self.h, self.v, self.w, self.x, self.y, self.z = self.stack.pop() 

477 

478 @_dispatch(min=143, max=146, state=_dvistate.inpage, args=('slen1',)) 

479 def _right(self, b): 

480 self.h += b 

481 

482 @_dispatch(min=147, max=151, state=_dvistate.inpage, args=('slen',)) 

483 def _right_w(self, new_w): 

484 if new_w is not None: 

485 self.w = new_w 

486 self.h += self.w 

487 

488 @_dispatch(min=152, max=156, state=_dvistate.inpage, args=('slen',)) 

489 def _right_x(self, new_x): 

490 if new_x is not None: 

491 self.x = new_x 

492 self.h += self.x 

493 

494 @_dispatch(min=157, max=160, state=_dvistate.inpage, args=('slen1',)) 

495 def _down(self, a): 

496 self.v += a 

497 

498 @_dispatch(min=161, max=165, state=_dvistate.inpage, args=('slen',)) 

499 def _down_y(self, new_y): 

500 if new_y is not None: 

501 self.y = new_y 

502 self.v += self.y 

503 

504 @_dispatch(min=166, max=170, state=_dvistate.inpage, args=('slen',)) 

505 def _down_z(self, new_z): 

506 if new_z is not None: 

507 self.z = new_z 

508 self.v += self.z 

509 

510 @_dispatch(min=171, max=234, state=_dvistate.inpage) 

511 def _fnt_num_immediate(self, k): 

512 self.f = k 

513 

514 @_dispatch(min=235, max=238, state=_dvistate.inpage, args=('olen1',)) 

515 def _fnt_num(self, new_f): 

516 self.f = new_f 

517 

518 @_dispatch(min=239, max=242, args=('ulen1',)) 

519 def _xxx(self, datalen): 

520 special = self.file.read(datalen) 

521 _log.debug( 

522 'Dvi._xxx: encountered special: %s', 

523 ''.join([chr(ch) if 32 <= ch < 127 else '<%02x>' % ch 

524 for ch in special])) 

525 

526 @_dispatch(min=243, max=246, args=('olen1', 'u4', 'u4', 'u4', 'u1', 'u1')) 

527 def _fnt_def(self, k, c, s, d, a, l): 

528 self._fnt_def_real(k, c, s, d, a, l) 

529 

530 def _fnt_def_real(self, k, c, s, d, a, l): 

531 n = self.file.read(a + l) 

532 fontname = n[-l:].decode('ascii') 

533 tfm = _tfmfile(fontname) 

534 if c != 0 and tfm.checksum != 0 and c != tfm.checksum: 

535 raise ValueError('tfm checksum mismatch: %s' % n) 

536 try: 

537 vf = _vffile(fontname) 

538 except FileNotFoundError: 

539 vf = None 

540 self.fonts[k] = DviFont(scale=s, tfm=tfm, texname=n, vf=vf) 

541 

542 @_dispatch(247, state=_dvistate.pre, args=('u1', 'u4', 'u4', 'u4', 'u1')) 

543 def _pre(self, i, num, den, mag, k): 

544 self.file.read(k) # comment in the dvi file 

545 if i != 2: 

546 raise ValueError("Unknown dvi format %d" % i) 

547 if num != 25400000 or den != 7227 * 2**16: 

548 raise ValueError("Nonstandard units in dvi file") 

549 # meaning: TeX always uses those exact values, so it 

550 # should be enough for us to support those 

551 # (There are 72.27 pt to an inch so 7227 pt = 

552 # 7227 * 2**16 sp to 100 in. The numerator is multiplied 

553 # by 10^5 to get units of 10**-7 meters.) 

554 if mag != 1000: 

555 raise ValueError("Nonstandard magnification in dvi file") 

556 # meaning: LaTeX seems to frown on setting \mag, so 

557 # I think we can assume this is constant 

558 self.state = _dvistate.outer 

559 

560 @_dispatch(248, state=_dvistate.outer) 

561 def _post(self, _): 

562 self.state = _dvistate.post_post 

563 # TODO: actually read the postamble and finale? 

564 # currently post_post just triggers closing the file 

565 

566 @_dispatch(249) 

567 def _post_post(self, _): 

568 raise NotImplementedError 

569 

570 @_dispatch(min=250, max=255) 

571 def _malformed(self, offset): 

572 raise ValueError(f"unknown command: byte {250 + offset}") 

573 

574 

575class DviFont: 

576 """ 

577 Encapsulation of a font that a DVI file can refer to. 

578 

579 This class holds a font's texname and size, supports comparison, 

580 and knows the widths of glyphs in the same units as the AFM file. 

581 There are also internal attributes (for use by dviread.py) that 

582 are *not* used for comparison. 

583 

584 The size is in Adobe points (converted from TeX points). 

585 

586 Parameters 

587 ---------- 

588 scale : float 

589 Factor by which the font is scaled from its natural size. 

590 tfm : Tfm 

591 TeX font metrics for this font 

592 texname : bytes 

593 Name of the font as used internally by TeX and friends, as an ASCII 

594 bytestring. This is usually very different from any external font 

595 names; `PsfontsMap` can be used to find the external name of the font. 

596 vf : Vf 

597 A TeX "virtual font" file, or None if this font is not virtual. 

598 

599 Attributes 

600 ---------- 

601 texname : bytes 

602 size : float 

603 Size of the font in Adobe points, converted from the slightly 

604 smaller TeX points. 

605 widths : list 

606 Widths of glyphs in glyph-space units, typically 1/1000ths of 

607 the point size. 

608 

609 """ 

610 __slots__ = ('texname', 'size', 'widths', '_scale', '_vf', '_tfm') 

611 

612 def __init__(self, scale, tfm, texname, vf): 

613 _api.check_isinstance(bytes, texname=texname) 

614 self._scale = scale 

615 self._tfm = tfm 

616 self.texname = texname 

617 self._vf = vf 

618 self.size = scale * (72.0 / (72.27 * 2**16)) 

619 try: 

620 nchars = max(tfm.width) + 1 

621 except ValueError: 

622 nchars = 0 

623 self.widths = [(1000*tfm.width.get(char, 0)) >> 20 

624 for char in range(nchars)] 

625 

626 def __eq__(self, other): 

627 return (type(self) == type(other) 

628 and self.texname == other.texname and self.size == other.size) 

629 

630 def __ne__(self, other): 

631 return not self.__eq__(other) 

632 

633 def __repr__(self): 

634 return "<{}: {}>".format(type(self).__name__, self.texname) 

635 

636 def _width_of(self, char): 

637 """Width of char in dvi units.""" 

638 width = self._tfm.width.get(char, None) 

639 if width is not None: 

640 return _mul2012(width, self._scale) 

641 _log.debug('No width for char %d in font %s.', char, self.texname) 

642 return 0 

643 

644 def _height_depth_of(self, char): 

645 """Height and depth of char in dvi units.""" 

646 result = [] 

647 for metric, name in ((self._tfm.height, "height"), 

648 (self._tfm.depth, "depth")): 

649 value = metric.get(char, None) 

650 if value is None: 

651 _log.debug('No %s for char %d in font %s', 

652 name, char, self.texname) 

653 result.append(0) 

654 else: 

655 result.append(_mul2012(value, self._scale)) 

656 # cmsyXX (symbols font) glyph 0 ("minus") has a nonzero descent 

657 # so that TeX aligns equations properly 

658 # (https://tex.stackexchange.com/q/526103/) 

659 # but we actually care about the rasterization depth to align 

660 # the dvipng-generated images. 

661 if re.match(br'^cmsy\d+$', self.texname) and char == 0: 

662 result[-1] = 0 

663 return result 

664 

665 

666class Vf(Dvi): 

667 r""" 

668 A virtual font (\*.vf file) containing subroutines for dvi files. 

669 

670 Parameters 

671 ---------- 

672 filename : str or path-like 

673 

674 Notes 

675 ----- 

676 The virtual font format is a derivative of dvi: 

677 http://mirrors.ctan.org/info/knuth/virtual-fonts 

678 This class reuses some of the machinery of `Dvi` 

679 but replaces the `_read` loop and dispatch mechanism. 

680 

681 Examples 

682 -------- 

683 :: 

684 

685 vf = Vf(filename) 

686 glyph = vf[code] 

687 glyph.text, glyph.boxes, glyph.width 

688 """ 

689 

690 def __init__(self, filename): 

691 super().__init__(filename, 0) 

692 try: 

693 self._first_font = None 

694 self._chars = {} 

695 self._read() 

696 finally: 

697 self.close() 

698 

699 def __getitem__(self, code): 

700 return self._chars[code] 

701 

702 def _read(self): 

703 """ 

704 Read one page from the file. Return True if successful, 

705 False if there were no more pages. 

706 """ 

707 packet_char, packet_ends = None, None 

708 packet_len, packet_width = None, None 

709 while True: 

710 byte = self.file.read(1)[0] 

711 # If we are in a packet, execute the dvi instructions 

712 if self.state is _dvistate.inpage: 

713 byte_at = self.file.tell()-1 

714 if byte_at == packet_ends: 

715 self._finalize_packet(packet_char, packet_width) 

716 packet_len, packet_char, packet_width = None, None, None 

717 # fall through to out-of-packet code 

718 elif byte_at > packet_ends: 

719 raise ValueError("Packet length mismatch in vf file") 

720 else: 

721 if byte in (139, 140) or byte >= 243: 

722 raise ValueError( 

723 "Inappropriate opcode %d in vf file" % byte) 

724 Dvi._dtable[byte](self, byte) 

725 continue 

726 

727 # We are outside a packet 

728 if byte < 242: # a short packet (length given by byte) 

729 packet_len = byte 

730 packet_char, packet_width = self._arg(1), self._arg(3) 

731 packet_ends = self._init_packet(byte) 

732 self.state = _dvistate.inpage 

733 elif byte == 242: # a long packet 

734 packet_len, packet_char, packet_width = \ 

735 [self._arg(x) for x in (4, 4, 4)] 

736 self._init_packet(packet_len) 

737 elif 243 <= byte <= 246: 

738 k = self._arg(byte - 242, byte == 246) 

739 c, s, d, a, l = [self._arg(x) for x in (4, 4, 4, 1, 1)] 

740 self._fnt_def_real(k, c, s, d, a, l) 

741 if self._first_font is None: 

742 self._first_font = k 

743 elif byte == 247: # preamble 

744 i, k = self._arg(1), self._arg(1) 

745 x = self.file.read(k) 

746 cs, ds = self._arg(4), self._arg(4) 

747 self._pre(i, x, cs, ds) 

748 elif byte == 248: # postamble (just some number of 248s) 

749 break 

750 else: 

751 raise ValueError("Unknown vf opcode %d" % byte) 

752 

753 def _init_packet(self, pl): 

754 if self.state != _dvistate.outer: 

755 raise ValueError("Misplaced packet in vf file") 

756 self.h, self.v, self.w, self.x, self.y, self.z = 0, 0, 0, 0, 0, 0 

757 self.stack, self.text, self.boxes = [], [], [] 

758 self.f = self._first_font 

759 return self.file.tell() + pl 

760 

761 def _finalize_packet(self, packet_char, packet_width): 

762 self._chars[packet_char] = Page( 

763 text=self.text, boxes=self.boxes, width=packet_width, 

764 height=None, descent=None) 

765 self.state = _dvistate.outer 

766 

767 def _pre(self, i, x, cs, ds): 

768 if self.state is not _dvistate.pre: 

769 raise ValueError("pre command in middle of vf file") 

770 if i != 202: 

771 raise ValueError("Unknown vf format %d" % i) 

772 if len(x): 

773 _log.debug('vf file comment: %s', x) 

774 self.state = _dvistate.outer 

775 # cs = checksum, ds = design size 

776 

777 

778def _mul2012(num1, num2): 

779 """Multiply two numbers in 20.12 fixed point format.""" 

780 # Separated into a function because >> has surprising precedence 

781 return (num1*num2) >> 20 

782 

783 

784class Tfm: 

785 """ 

786 A TeX Font Metric file. 

787 

788 This implementation covers only the bare minimum needed by the Dvi class. 

789 

790 Parameters 

791 ---------- 

792 filename : str or path-like 

793 

794 Attributes 

795 ---------- 

796 checksum : int 

797 Used for verifying against the dvi file. 

798 design_size : int 

799 Design size of the font (unknown units) 

800 width, height, depth : dict 

801 Dimensions of each character, need to be scaled by the factor 

802 specified in the dvi file. These are dicts because indexing may 

803 not start from 0. 

804 """ 

805 __slots__ = ('checksum', 'design_size', 'width', 'height', 'depth') 

806 

807 def __init__(self, filename): 

808 _log.debug('opening tfm file %s', filename) 

809 with open(filename, 'rb') as file: 

810 header1 = file.read(24) 

811 lh, bc, ec, nw, nh, nd = struct.unpack('!6H', header1[2:14]) 

812 _log.debug('lh=%d, bc=%d, ec=%d, nw=%d, nh=%d, nd=%d', 

813 lh, bc, ec, nw, nh, nd) 

814 header2 = file.read(4*lh) 

815 self.checksum, self.design_size = struct.unpack('!2I', header2[:8]) 

816 # there is also encoding information etc. 

817 char_info = file.read(4*(ec-bc+1)) 

818 widths = struct.unpack(f'!{nw}i', file.read(4*nw)) 

819 heights = struct.unpack(f'!{nh}i', file.read(4*nh)) 

820 depths = struct.unpack(f'!{nd}i', file.read(4*nd)) 

821 self.width, self.height, self.depth = {}, {}, {} 

822 for idx, char in enumerate(range(bc, ec+1)): 

823 byte0 = char_info[4*idx] 

824 byte1 = char_info[4*idx+1] 

825 self.width[char] = widths[byte0] 

826 self.height[char] = heights[byte1 >> 4] 

827 self.depth[char] = depths[byte1 & 0xf] 

828 

829 

830PsFont = namedtuple('PsFont', 'texname psname effects encoding filename') 

831 

832 

833class PsfontsMap: 

834 """ 

835 A psfonts.map formatted file, mapping TeX fonts to PS fonts. 

836 

837 Parameters 

838 ---------- 

839 filename : str or path-like 

840 

841 Notes 

842 ----- 

843 For historical reasons, TeX knows many Type-1 fonts by different 

844 names than the outside world. (For one thing, the names have to 

845 fit in eight characters.) Also, TeX's native fonts are not Type-1 

846 but Metafont, which is nontrivial to convert to PostScript except 

847 as a bitmap. While high-quality conversions to Type-1 format exist 

848 and are shipped with modern TeX distributions, we need to know 

849 which Type-1 fonts are the counterparts of which native fonts. For 

850 these reasons a mapping is needed from internal font names to font 

851 file names. 

852 

853 A texmf tree typically includes mapping files called e.g. 

854 :file:`psfonts.map`, :file:`pdftex.map`, or :file:`dvipdfm.map`. 

855 The file :file:`psfonts.map` is used by :program:`dvips`, 

856 :file:`pdftex.map` by :program:`pdfTeX`, and :file:`dvipdfm.map` 

857 by :program:`dvipdfm`. :file:`psfonts.map` might avoid embedding 

858 the 35 PostScript fonts (i.e., have no filename for them, as in 

859 the Times-Bold example above), while the pdf-related files perhaps 

860 only avoid the "Base 14" pdf fonts. But the user may have 

861 configured these files differently. 

862 

863 Examples 

864 -------- 

865 >>> map = PsfontsMap(find_tex_file('pdftex.map')) 

866 >>> entry = map[b'ptmbo8r'] 

867 >>> entry.texname 

868 b'ptmbo8r' 

869 >>> entry.psname 

870 b'Times-Bold' 

871 >>> entry.encoding 

872 '/usr/local/texlive/2008/texmf-dist/fonts/enc/dvips/base/8r.enc' 

873 >>> entry.effects 

874 {'slant': 0.16700000000000001} 

875 >>> entry.filename 

876 """ 

877 __slots__ = ('_filename', '_unparsed', '_parsed') 

878 

879 # Create a filename -> PsfontsMap cache, so that calling 

880 # `PsfontsMap(filename)` with the same filename a second time immediately 

881 # returns the same object. 

882 @lru_cache() 

883 def __new__(cls, filename): 

884 self = object.__new__(cls) 

885 self._filename = os.fsdecode(filename) 

886 # Some TeX distributions have enormous pdftex.map files which would 

887 # take hundreds of milliseconds to parse, but it is easy enough to just 

888 # store the unparsed lines (keyed by the first word, which is the 

889 # texname) and parse them on-demand. 

890 with open(filename, 'rb') as file: 

891 self._unparsed = {} 

892 for line in file: 

893 tfmname = line.split(b' ', 1)[0] 

894 self._unparsed.setdefault(tfmname, []).append(line) 

895 self._parsed = {} 

896 return self 

897 

898 def __getitem__(self, texname): 

899 assert isinstance(texname, bytes) 

900 if texname in self._unparsed: 

901 for line in self._unparsed.pop(texname): 

902 if self._parse_and_cache_line(line): 

903 break 

904 try: 

905 return self._parsed[texname] 

906 except KeyError: 

907 raise LookupError( 

908 f"An associated PostScript font (required by Matplotlib) " 

909 f"could not be found for TeX font {texname.decode('ascii')!r} " 

910 f"in {self._filename!r}; this problem can often be solved by " 

911 f"installing a suitable PostScript font package in your TeX " 

912 f"package manager") from None 

913 

914 def _parse_and_cache_line(self, line): 

915 """ 

916 Parse a line in the font mapping file. 

917 

918 The format is (partially) documented at 

919 http://mirrors.ctan.org/systems/doc/pdftex/manual/pdftex-a.pdf 

920 https://tug.org/texinfohtml/dvips.html#psfonts_002emap 

921 Each line can have the following fields: 

922 

923 - tfmname (first, only required field), 

924 - psname (defaults to tfmname, must come immediately after tfmname if 

925 present), 

926 - fontflags (integer, must come immediately after psname if present, 

927 ignored by us), 

928 - special (SlantFont and ExtendFont, only field that is double-quoted), 

929 - fontfile, encodingfile (optional, prefixed by <, <<, or <[; << always 

930 precedes a font, <[ always precedes an encoding, < can precede either 

931 but then an encoding file must have extension .enc; < and << also 

932 request different font subsetting behaviors but we ignore that; < can 

933 be separated from the filename by whitespace). 

934 

935 special, fontfile, and encodingfile can appear in any order. 

936 """ 

937 # If the map file specifies multiple encodings for a font, we 

938 # follow pdfTeX in choosing the last one specified. Such 

939 # entries are probably mistakes but they have occurred. 

940 # https://tex.stackexchange.com/q/10826/ 

941 

942 if not line or line.startswith((b" ", b"%", b"*", b";", b"#")): 

943 return 

944 tfmname = basename = special = encodingfile = fontfile = None 

945 is_subsetted = is_t1 = is_truetype = False 

946 matches = re.finditer(br'"([^"]*)(?:"|$)|(\S+)', line) 

947 for match in matches: 

948 quoted, unquoted = match.groups() 

949 if unquoted: 

950 if unquoted.startswith(b"<<"): # font 

951 fontfile = unquoted[2:] 

952 elif unquoted.startswith(b"<["): # encoding 

953 encodingfile = unquoted[2:] 

954 elif unquoted.startswith(b"<"): # font or encoding 

955 word = ( 

956 # <foo => foo 

957 unquoted[1:] 

958 # < by itself => read the next word 

959 or next(filter(None, next(matches).groups()))) 

960 if word.endswith(b".enc"): 

961 encodingfile = word 

962 else: 

963 fontfile = word 

964 is_subsetted = True 

965 elif tfmname is None: 

966 tfmname = unquoted 

967 elif basename is None: 

968 basename = unquoted 

969 elif quoted: 

970 special = quoted 

971 effects = {} 

972 if special: 

973 words = reversed(special.split()) 

974 for word in words: 

975 if word == b"SlantFont": 

976 effects["slant"] = float(next(words)) 

977 elif word == b"ExtendFont": 

978 effects["extend"] = float(next(words)) 

979 

980 # Verify some properties of the line that would cause it to be ignored 

981 # otherwise. 

982 if fontfile is not None: 

983 if fontfile.endswith((b".ttf", b".ttc")): 

984 is_truetype = True 

985 elif not fontfile.endswith(b".otf"): 

986 is_t1 = True 

987 elif basename is not None: 

988 is_t1 = True 

989 if is_truetype and is_subsetted and encodingfile is None: 

990 return 

991 if not is_t1 and ("slant" in effects or "extend" in effects): 

992 return 

993 if abs(effects.get("slant", 0)) > 1: 

994 return 

995 if abs(effects.get("extend", 0)) > 2: 

996 return 

997 

998 if basename is None: 

999 basename = tfmname 

1000 if encodingfile is not None: 

1001 encodingfile = _find_tex_file(encodingfile) 

1002 if fontfile is not None: 

1003 fontfile = _find_tex_file(fontfile) 

1004 self._parsed[tfmname] = PsFont( 

1005 texname=tfmname, psname=basename, effects=effects, 

1006 encoding=encodingfile, filename=fontfile) 

1007 return True 

1008 

1009 

1010def _parse_enc(path): 

1011 r""" 

1012 Parse a \*.enc file referenced from a psfonts.map style file. 

1013 

1014 The format supported by this function is a tiny subset of PostScript. 

1015 

1016 Parameters 

1017 ---------- 

1018 path : `os.PathLike` 

1019 

1020 Returns 

1021 ------- 

1022 list 

1023 The nth entry of the list is the PostScript glyph name of the nth 

1024 glyph. 

1025 """ 

1026 no_comments = re.sub("%.*", "", Path(path).read_text(encoding="ascii")) 

1027 array = re.search(r"(?s)\[(.*)\]", no_comments).group(1) 

1028 lines = [line for line in array.split() if line] 

1029 if all(line.startswith("/") for line in lines): 

1030 return [line[1:] for line in lines] 

1031 else: 

1032 raise ValueError( 

1033 "Failed to parse {} as Postscript encoding".format(path)) 

1034 

1035 

1036class _LuatexKpsewhich: 

1037 @lru_cache() # A singleton. 

1038 def __new__(cls): 

1039 self = object.__new__(cls) 

1040 self._proc = self._new_proc() 

1041 return self 

1042 

1043 def _new_proc(self): 

1044 return subprocess.Popen( 

1045 ["luatex", "--luaonly", 

1046 str(cbook._get_data_path("kpsewhich.lua"))], 

1047 stdin=subprocess.PIPE, stdout=subprocess.PIPE) 

1048 

1049 def search(self, filename): 

1050 if self._proc.poll() is not None: # Dead, restart it. 

1051 self._proc = self._new_proc() 

1052 self._proc.stdin.write(os.fsencode(filename) + b"\n") 

1053 self._proc.stdin.flush() 

1054 out = self._proc.stdout.readline().rstrip() 

1055 return None if out == b"nil" else os.fsdecode(out) 

1056 

1057 

1058@lru_cache() 

1059@_api.delete_parameter("3.5", "format") 

1060def _find_tex_file(filename, format=None): 

1061 """ 

1062 Find a file in the texmf tree using kpathsea_. 

1063 

1064 The kpathsea library, provided by most existing TeX distributions, both 

1065 on Unix-like systems and on Windows (MikTeX), is invoked via a long-lived 

1066 luatex process if luatex is installed, or via kpsewhich otherwise. 

1067 

1068 .. _kpathsea: https://www.tug.org/kpathsea/ 

1069 

1070 Parameters 

1071 ---------- 

1072 filename : str or path-like 

1073 format : str or bytes 

1074 Used as the value of the ``--format`` option to :program:`kpsewhich`. 

1075 Could be e.g. 'tfm' or 'vf' to limit the search to that type of files. 

1076 Deprecated. 

1077 

1078 Raises 

1079 ------ 

1080 FileNotFoundError 

1081 If the file is not found. 

1082 """ 

1083 

1084 # we expect these to always be ascii encoded, but use utf-8 

1085 # out of caution 

1086 if isinstance(filename, bytes): 

1087 filename = filename.decode('utf-8', errors='replace') 

1088 if isinstance(format, bytes): 

1089 format = format.decode('utf-8', errors='replace') 

1090 

1091 try: 

1092 lk = _LuatexKpsewhich() 

1093 except FileNotFoundError: 

1094 lk = None # Fallback to directly calling kpsewhich, as below. 

1095 

1096 if lk and format is None: 

1097 path = lk.search(filename) 

1098 

1099 else: 

1100 if os.name == 'nt': 

1101 # On Windows only, kpathsea can use utf-8 for cmd args and output. 

1102 # The `command_line_encoding` environment variable is set to force 

1103 # it to always use utf-8 encoding. See Matplotlib issue #11848. 

1104 kwargs = {'env': {**os.environ, 'command_line_encoding': 'utf-8'}, 

1105 'encoding': 'utf-8'} 

1106 else: # On POSIX, run through the equivalent of os.fsdecode(). 

1107 kwargs = {'encoding': sys.getfilesystemencoding(), 

1108 'errors': 'surrogateescape'} 

1109 

1110 cmd = ['kpsewhich'] 

1111 if format is not None: 

1112 cmd += ['--format=' + format] 

1113 cmd += [filename] 

1114 try: 

1115 path = (cbook._check_and_log_subprocess(cmd, _log, **kwargs) 

1116 .rstrip('\n')) 

1117 except (FileNotFoundError, RuntimeError): 

1118 path = None 

1119 

1120 if path: 

1121 return path 

1122 else: 

1123 raise FileNotFoundError( 

1124 f"Matplotlib's TeX implementation searched for a file named " 

1125 f"{filename!r} in your texmf tree, but could not find it") 

1126 

1127 

1128# After the deprecation period elapses, delete this shim and rename 

1129# _find_tex_file to find_tex_file everywhere. 

1130@_api.delete_parameter("3.5", "format") 

1131def find_tex_file(filename, format=None): 

1132 try: 

1133 return (_find_tex_file(filename, format) if format is not None else 

1134 _find_tex_file(filename)) 

1135 except FileNotFoundError as exc: 

1136 _api.warn_deprecated( 

1137 "3.6", message=f"{exc.args[0]}; in the future, this will raise a " 

1138 f"FileNotFoundError.") 

1139 return "" 

1140 

1141 

1142find_tex_file.__doc__ = _find_tex_file.__doc__ 

1143 

1144 

1145@lru_cache() 

1146def _fontfile(cls, suffix, texname): 

1147 return cls(_find_tex_file(texname + suffix)) 

1148 

1149 

1150_tfmfile = partial(_fontfile, Tfm, ".tfm") 

1151_vffile = partial(_fontfile, Vf, ".vf") 

1152 

1153 

1154if __name__ == '__main__': 

1155 from argparse import ArgumentParser 

1156 import itertools 

1157 

1158 parser = ArgumentParser() 

1159 parser.add_argument("filename") 

1160 parser.add_argument("dpi", nargs="?", type=float, default=None) 

1161 args = parser.parse_args() 

1162 with Dvi(args.filename, args.dpi) as dvi: 

1163 fontmap = PsfontsMap(_find_tex_file('pdftex.map')) 

1164 for page in dvi: 

1165 print(f"=== new page === " 

1166 f"(w: {page.width}, h: {page.height}, d: {page.descent})") 

1167 for font, group in itertools.groupby( 

1168 page.text, lambda text: text.font): 

1169 print(f"font: {font.texname.decode('latin-1')!r}\t" 

1170 f"scale: {font._scale / 2 ** 20}") 

1171 print("x", "y", "glyph", "chr", "w", "(glyphs)", sep="\t") 

1172 for text in group: 

1173 print(text.x, text.y, text.glyph, 

1174 chr(text.glyph) if chr(text.glyph).isprintable() 

1175 else ".", 

1176 text.width, sep="\t") 

1177 if page.boxes: 

1178 print("x", "y", "h", "w", "", "(boxes)", sep="\t") 

1179 for box in page.boxes: 

1180 print(box.x, box.y, box.height, box.width, sep="\t")