gooderp18绿色标准版
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

541 line
16KB

  1. #!C:\odoobuild\WinPy64\python-3.12.3.amd64\python.exe
  2. # Imported from //depot/prj/plan9topam/master/code/plan9topam.py#4 on
  3. # 2009-06-15.
  4. """Command line tool to convert from Plan 9 image format to PNG format.
  5. Plan 9 image format description:
  6. https://plan9.io/magic/man2html/6/image
  7. Where possible this tool will use unbuffered read() calls,
  8. so that when finished the file offset is exactly at the end of
  9. the image data.
  10. This is useful for Plan9 subfont files which place font metric
  11. data immediately after the image.
  12. """
  13. # Test materials
  14. # asset/left.bit is a Plan 9 image file, a leftwards facing Glenda.
  15. # Other materials have to be scrounged from the internet.
  16. # https://plan9.io/sources/plan9/sys/games/lib/sokoban/images/cargo.bit
  17. import array
  18. import collections
  19. import io
  20. # http://www.python.org/doc/2.3.5/lib/module-itertools.html
  21. import itertools
  22. import os
  23. # http://www.python.org/doc/2.3.5/lib/module-re.html
  24. import re
  25. import struct
  26. # http://www.python.org/doc/2.3.5/lib/module-sys.html
  27. import sys
  28. # https://docs.python.org/3/library/tarfile.html
  29. import tarfile
  30. # https://pypi.org/project/pypng/
  31. import png
  32. # internal
  33. import prix
  34. class Error(Exception):
  35. """Some sort of Plan 9 image error."""
  36. def block(s, n):
  37. return zip(*[iter(s)] * n)
  38. def plan9_as_image(inp):
  39. """Represent a Plan 9 image file as a png.Image instance, so
  40. that it can be written as a PNG file.
  41. Works with compressed input files and may work with uncompressed files.
  42. """
  43. # Use inp.raw if available.
  44. # This avoids buffering and means that when the image is processed,
  45. # the resulting input stream is cued up exactly at the end
  46. # of the image.
  47. inp = getattr(inp, "raw", inp)
  48. info, blocks = plan9_open_image(inp)
  49. rows, infodict = plan9_image_rows(blocks, info)
  50. return png.Image(rows, infodict)
  51. def plan9_open_image(inp):
  52. """Open a Plan9 image file (`inp` should be an already open
  53. file object), and return (`info`, `blocks`) pair.
  54. `info` should be a Plan9 5-tuple;
  55. `blocks` is the input, and it should yield (`row`, `data`)
  56. pairs (see :meth:`pixmeta`).
  57. """
  58. r = inp.read(11)
  59. if r == b"compressed\n":
  60. info, blocks = decompress(inp)
  61. else:
  62. # Since Python 3, there is a good chance that this path
  63. # doesn't work.
  64. info, blocks = glue(inp, r)
  65. return info, blocks
  66. def glue(f, r):
  67. """Return (info, stream) pair, given `r` the initial portion of
  68. the metadata that has already been read from the stream `f`.
  69. """
  70. r = r + f.read(60 - len(r))
  71. return (meta(r), f)
  72. def meta(r):
  73. """Convert 60 byte bytestring `r`, the metadata from an image file.
  74. Returns a 5-tuple (*chan*,*minx*,*miny*,*limx*,*limy*).
  75. 5-tuples may settle into lists in transit.
  76. As per https://plan9.io/magic/man2html/6/image the metadata
  77. comprises 5 words separated by blanks.
  78. As it happens each word starts at an index that is a multiple of 12,
  79. but this routine does not care about that.
  80. """
  81. r = r.split()
  82. # :todo: raise FormatError
  83. if 5 != len(r):
  84. raise Error("Expected 5 space-separated words in metadata")
  85. r = [r[0]] + [int(x) for x in r[1:]]
  86. return r
  87. def bitdepthof(chan):
  88. """Return the bitdepth for a Plan9 pixel format string."""
  89. maxd = 0
  90. for c in re.findall(rb"[a-z]\d*", chan):
  91. if c[0] != "x":
  92. maxd = max(maxd, int(c[1:]))
  93. return maxd
  94. def maxvalof(chan):
  95. """Return the netpbm MAXVAL for a Plan9 pixel format string."""
  96. bitdepth = bitdepthof(chan)
  97. return (2 ** bitdepth) - 1
  98. def plan9_image_rows(blocks, metadata):
  99. """
  100. Convert (uncompressed) Plan 9 image file to pair of (*rows*, *info*).
  101. This is intended to be used by PyPNG format.
  102. *info* is the image info (metadata) returned in a dictionary,
  103. *rows* is an iterator that yields each row in
  104. boxed row flat pixel format.
  105. `blocks`, should be an iterator of (`row`, `data`) pairs.
  106. """
  107. chan, minx, miny, limx, limy = metadata
  108. rows = limy - miny
  109. width = limx - minx
  110. nchans = len(re.findall(b"[a-wyz]", chan))
  111. alpha = b"a" in chan
  112. # Iverson's convention for the win!
  113. ncolour = nchans - alpha
  114. greyscale = ncolour == 1
  115. bitdepth = bitdepthof(chan)
  116. maxval = maxvalof(chan)
  117. # PNG style info dict.
  118. meta = dict(
  119. size=(width, rows),
  120. bitdepth=bitdepth,
  121. greyscale=greyscale,
  122. alpha=alpha,
  123. planes=nchans,
  124. )
  125. arraycode = "BH"[bitdepth > 8]
  126. return (
  127. map(
  128. lambda x: array.array(arraycode, itertools.chain(*x)),
  129. block(unpack(blocks, rows, width, chan, maxval), width),
  130. ),
  131. meta,
  132. )
  133. def unpack(f, rows, width, chan, maxval):
  134. """Unpack `f` into pixels.
  135. `chan` describes the pixel format using
  136. the Plan9 syntax ("k8", "r8g8b8", and so on).
  137. Assumes the pixel format has a total channel bit depth
  138. that is either a multiple or a divisor of 8
  139. (the Plan9 image specification requires this).
  140. `f` should be an iterator that returns blocks of input such that
  141. each block contains a whole number of pixels.
  142. The return value is an iterator that yields each pixel as an n-tuple.
  143. """
  144. def mask(w):
  145. """An integer, to be used as a mask, with bottom `w` bits set to 1."""
  146. return (1 << w) - 1
  147. def deblock(f, depth, width):
  148. """A "packer" used to convert multiple bytes into single pixels.
  149. `depth` is the pixel depth in bits (>= 8), `width` is the row width in
  150. pixels.
  151. """
  152. w = depth // 8
  153. i = 0
  154. for block in f:
  155. for i in range(len(block) // w):
  156. p = block[w * i : w * (i + 1)]
  157. i += w
  158. # Convert little-endian p to integer x
  159. x = 0
  160. s = 1 # scale
  161. for j in p:
  162. x += s * j
  163. s <<= 8
  164. yield x
  165. def bitfunge(f, depth, width):
  166. """A "packer" used to convert single bytes into multiple pixels.
  167. Depth is the pixel depth (< 8), width is the row width in pixels.
  168. """
  169. assert 8 / depth == 8 // depth
  170. for block in f:
  171. col = 0
  172. for x in block:
  173. for j in range(8 // depth):
  174. yield x >> (8 - depth)
  175. col += 1
  176. if col == width:
  177. # A row-end forces a new byte even if
  178. # we haven't consumed all of the current byte.
  179. # Effectively rows are bit-padded to make
  180. # a whole number of bytes.
  181. col = 0
  182. break
  183. x <<= depth
  184. # number of bits in each channel
  185. bits = [int(d) for d in re.findall(rb"\d+", chan)]
  186. # colr of each channel
  187. # (r, g, b, k for actual colours, and
  188. # a, m, x for alpha, map-index, and unused)
  189. colr = re.findall(b"[a-z]", chan)
  190. depth = sum(bits)
  191. # Select a "packer" that either:
  192. # - gathers multiple bytes into a single pixel (for depth >= 8); or,
  193. # - splits bytes into several pixels (for depth < 8).
  194. if depth >= 8:
  195. assert depth % 8 == 0
  196. packer = deblock
  197. else:
  198. assert 8 % depth == 0
  199. packer = bitfunge
  200. for x in packer(f, depth, width):
  201. # x is the pixel as an unsigned integer
  202. o = []
  203. # This is a bit yucky.
  204. # Extract each channel from the _most_ significant part of x.
  205. for b, col in zip(bits, colr):
  206. v = (x >> (depth - b)) & mask(b)
  207. x <<= b
  208. if col != "x":
  209. # scale to maxval
  210. v = v * float(maxval) / mask(b)
  211. v = int(v + 0.5)
  212. o.append(v)
  213. yield o
  214. def decompress(f):
  215. """Decompress a Plan 9 image file.
  216. The input `f` should be a binary file object that
  217. is already cued past the initial 'compressed\n' string.
  218. The return result is (`info`, `blocks`);
  219. `info` is a 5-tuple of the Plan 9 image metadata;
  220. `blocks` is an iterator that yields a (row, data) pair
  221. for each block of data.
  222. """
  223. r = meta(f.read(60))
  224. return r, decomprest(f, r[4])
  225. def decomprest(f, rows):
  226. """Iterator that decompresses the rest of a file once the metadata
  227. have been consumed."""
  228. row = 0
  229. while row < rows:
  230. row, o = deblock(f)
  231. yield o
  232. def deblock(f):
  233. """Decompress a single block from a compressed Plan 9 image file.
  234. Each block starts with 2 decimal strings of 12 bytes each.
  235. Yields a sequence of (row, data) pairs where
  236. `row` is the total number of rows processed
  237. (according to the file format) and
  238. `data` is the decompressed data for this block.
  239. """
  240. row = int(f.read(12))
  241. size = int(f.read(12))
  242. if not (0 <= size <= 6000):
  243. raise Error("block has invalid size; not a Plan 9 image file?")
  244. # Since each block is at most 6000 bytes we may as well read it all in
  245. # one go.
  246. d = f.read(size)
  247. i = 0
  248. o = []
  249. while i < size:
  250. x = d[i]
  251. i += 1
  252. if x & 0x80:
  253. x = (x & 0x7F) + 1
  254. lit = d[i : i + x]
  255. i += x
  256. o.extend(lit)
  257. continue
  258. # x's high-order bit is 0
  259. length = (x >> 2) + 3
  260. # Offset is made from bottom 2 bits of x and 8 bits of next byte.
  261. # MSByte LSByte
  262. # +---------------------+-------------------------+
  263. # | - - - - - - | x1 x0 | d7 d6 d5 d4 d3 d2 d1 d0 |
  264. # +-----------------------------------------------+
  265. # Had to discover by inspection which way round the bits go,
  266. # because https://plan9.io/magic/man2html/6/image doesn't say.
  267. # that x's 2 bits are most significant.
  268. offset = (x & 3) << 8
  269. offset |= d[i]
  270. i += 1
  271. # Note: complement operator neatly maps (0 to 1023) to (-1 to
  272. # -1024). Adding len(o) gives a (non-negative) offset into o from
  273. # which to start indexing.
  274. offset = ~offset + len(o)
  275. if offset < 0:
  276. raise Error(
  277. "byte offset indexes off the begininning of "
  278. "the output buffer; not a Plan 9 image file?"
  279. )
  280. for j in range(length):
  281. o.append(o[offset + j])
  282. return row, bytes(o)
  283. FontChar = collections.namedtuple("FontChar", "x top bottom left width")
  284. def font_copy(inp, image, out, control):
  285. """
  286. Convert a Plan 9 font (`inp`, `image`) to a series of PNG images,
  287. and write them out as a tar file to the file object `out`.
  288. Write a text control file out to the file object `control`.
  289. Each valid glyph in the font becomes a single PNG image;
  290. the output is a tar file of all the images.
  291. A Plan 9 font consists of a Plan 9 image immediately
  292. followed by font data.
  293. The image for the font should be the `image` argument,
  294. the file containing the rest of the font data should be the
  295. file object `inp` which should be cued up to the start of
  296. the font data that immediately follows the image.
  297. https://plan9.io/magic/man2html/6/font
  298. """
  299. # The format is a little unusual, and isn't completely
  300. # clearly documented.
  301. # Each 6-byte structure (see FontChar above) defines
  302. # a rectangular region of the image that is used for each
  303. # glyph.
  304. # The source image region that is used may be strictly
  305. # smaller than the rectangle for the target glyph.
  306. # This seems like a micro-optimisation.
  307. # For each glyph,
  308. # rows above `top` and below `bottom` will not be copied
  309. # from the source (they can be assumed to be blank).
  310. # No space is saved in the source image, since the rows must
  311. # be present.
  312. # `x` is always non-decreasing, so the glyphs appear strictly
  313. # left-to-image in the source image.
  314. # The x of the next glyph is used to
  315. # infer the width of the source rectangle.
  316. # `top` and `bottom` give the y-coordinate of the top- and
  317. # bottom- sides of the rectangle in both source and targets.
  318. # `left` is the x-coordinate of the left-side of the
  319. # rectangle in the target glyph. (equivalently, the amount
  320. # of padding that should be added on the left).
  321. # `width` is the advance-width of the glyph; by convention
  322. # it is 0 for an undefined glyph.
  323. name = getattr(inp, "name", "*subfont*name*not*supplied*")
  324. header = inp.read(36)
  325. n, height, ascent = [int(x) for x in header.split()]
  326. print("baseline", name, ascent, file=control, sep=",")
  327. chs = []
  328. for i in range(n + 1):
  329. bs = inp.read(6)
  330. ch = FontChar(*struct.unpack("<HBBBB", bs))
  331. chs.append(ch)
  332. tar = tarfile.open(mode="w|", fileobj=out)
  333. # Start at 0, increment for every image output
  334. # (recall that not every input glyph has an output image)
  335. output_index = 0
  336. for i in range(n):
  337. ch = chs[i]
  338. if ch.width == 0:
  339. continue
  340. print("png", "index", output_index, "glyph", name, i, file=control, sep=",")
  341. info = dict(image.info, size=(ch.width, height))
  342. target = new_image(info)
  343. source_width = chs[i + 1].x - ch.x
  344. rect = ((ch.left, ch.top), (ch.left + source_width, ch.bottom))
  345. image_draw(target, rect, image, (ch.x, ch.top))
  346. # :todo: add source, glyph, and baseline data here (as a
  347. # private tag?)
  348. o = io.BytesIO()
  349. target.write(o)
  350. binary_size = o.tell()
  351. o.seek(0)
  352. tarinfo = tar.gettarinfo(arcname="%s/glyph%d.png" % (name, i), fileobj=inp)
  353. tarinfo.size = binary_size
  354. tar.addfile(tarinfo, fileobj=o)
  355. output_index += 1
  356. tar.close()
  357. def new_image(info):
  358. """Return a fresh png.Image instance."""
  359. width, height = info["size"]
  360. vpr = width * info["planes"]
  361. row = lambda: [0] * vpr
  362. rows = [row() for _ in range(height)]
  363. return png.Image(rows, info)
  364. def image_draw(target, rect, source, point):
  365. """The point `point` in the source image is aligned with the
  366. top-left of rect in the target image, and then the rectangle
  367. in target is replaced with the pixels from `source`.
  368. This routine assumes that both source and target can have
  369. their rows objects indexed (not streamed).
  370. """
  371. # :todo: there is no attempt to do clipping or channel or
  372. # colour conversion. But maybe later?
  373. if target.info["planes"] != source.info["planes"]:
  374. raise NotImplementedError(
  375. "source and target must have the same number of planes"
  376. )
  377. if target.info["bitdepth"] != source.info["bitdepth"]:
  378. raise NotImplementedError("source and target must have the same bitdepth")
  379. tl, br = rect
  380. left, top = tl
  381. right, bottom = br
  382. height = bottom - top
  383. planes = source.info["planes"]
  384. vpr = (right - left) * planes
  385. source_left, source_top = point
  386. source_l = source_left * planes
  387. source_r = source_l + vpr
  388. target_l = left * planes
  389. target_r = target_l + vpr
  390. for y in range(height):
  391. row = source.rows[y + source_top]
  392. row = row[source_l:source_r]
  393. target.rows[top + y][target_l:target_r] = row
  394. def main(argv=None):
  395. import argparse
  396. parser = argparse.ArgumentParser(description="Convert Plan9 image to PNG")
  397. parser.add_argument(
  398. "input",
  399. nargs="?",
  400. default="-",
  401. type=png.cli_open,
  402. metavar="image",
  403. help="image file in Plan 9 format",
  404. )
  405. parser.add_argument(
  406. "--control",
  407. default=os.path.devnull,
  408. type=argparse.FileType("w"),
  409. metavar="ControlCSV",
  410. help="(when using --font) write a control CSV file to named file",
  411. )
  412. parser.add_argument(
  413. "--font",
  414. action="store_true",
  415. help="process as Plan 9 subfont: output a tar file of PNGs",
  416. )
  417. args = parser.parse_args()
  418. image = plan9_as_image(args.input)
  419. image.stream()
  420. if not args.font:
  421. image.write(png.binary_stdout())
  422. else:
  423. font_copy(args.input, image, png.binary_stdout(), args.control)
  424. if __name__ == "__main__":
  425. sys.exit(main())
上海开阖软件有限公司 沪ICP备12045867号-1