creamygoat

PixInSVG - Embed an image into an SVG

Nov 5th, 2025
194
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 13.16 KB | None | 0 0
  1. #!/usr/bin/python3
  2.  
  3. """
  4. ; PixinSVG - Create an SVG consisting of an embedded image
  5. ; Version: 1.0.0.0
  6. ; (c) Copyright 2025, Daniel Neville
  7.  
  8. ; This program is free software: you can redistribute it and/or modify
  9. ; it under the terms of the GNU General Public License as published by
  10. ; the Free Software Foundation, either version 3 of the License, or
  11. ; (at your option) any later version.
  12. ;
  13. ; This program is distributed in the hope that it will be useful,
  14. ; but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. ; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. ; GNU General Public License for more details.
  17. ;
  18. ; You should have received a copy of the GNU General Public License
  19. ; along with this program. If not, see <https://www.gnu.org/licenses/>.
  20. -----
  21.  
  22. This script wraps SVG code around an image so that the pixelated
  23. scaling mode will be preserved for the benefit of T-shirt printers
  24. which might spoil pixel art with smooth shading.
  25.  
  26. Example usage:
  27.  pixinsvg -width 20cm -p 1:1 -s pixelated PizzaGlue.png PizzaGlue.svg
  28.  
  29. """
  30.  
  31. import os
  32. import sys
  33. import traceback
  34. #import math
  35. #import numpy as np
  36. import itertools
  37. import re
  38. import html
  39. import argparse
  40. from PIL import Image
  41. import base64
  42.  
  43.  
  44. VERSION = '1.0.0.0'
  45.  
  46.  
  47. class Error (Exception):
  48.   pass
  49.  
  50. class ArgError (Error):
  51.   pass
  52.  
  53. class FileError(Error):
  54.   pass
  55.  
  56. class DataError(Error):
  57.   pass
  58.  
  59. class CmdError(Error):
  60.   pass
  61.  
  62.  
  63. #-----------------------------------------------------------------------------
  64. # Number formatting and text handling functions
  65. # (These should really be in a separate file.)
  66. #-----------------------------------------------------------------------------
  67.  
  68.  
  69. # Regular expressions
  70.  
  71. re_std_decimal_number = fp = re.compile(
  72.     r"^[+-]?([0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)([Ee][-+]?[0-9]+)?")
  73. re_trailing_zeros = re.compile(r".*?\.[0-9]+?(0+)(?:[Ee][^Ee]*)?$")
  74. re_decimal_zeros = re.compile(r".*?(\.0*)(?:[Ee][^Ee]*)?$")
  75. re_neg_zero = re.compile(
  76.     r"^([-\u2212])(0+\.0*|0*\.0+)([Ee][+-\u2212]?[0-9]+)?$")
  77.  
  78.  
  79. #-----------------------------------------------------------------------------
  80.  
  81.  
  82. def remove_trailing_zeros(x, keep_dp=False):
  83.   """Return a string representation of a number with trailing zeros stripped.
  84.  
  85.  The number may already be a string representation, in which case, any
  86.  spurious trailing decimal point will be removed.
  87.  
  88.  If keep_dp is False (the default), the removal of trailing zeros extends
  89.  to the removal of the decimal point as well.
  90.  
  91.  Numbers expressed in E notation are handled correctly.
  92.  
  93.  This function requires any decimal point to be preceded by a digit.
  94.  """
  95.   s = str(x)
  96.   if m := re_trailing_zeros.match(s):
  97.     span = m.span(1)
  98.     s = s[:span[0]] + s[span[1]:]
  99.   if not keep_dp:
  100.     if m := re_decimal_zeros.match(s):
  101.       span = m.span(1)
  102.       s = s[:span[0]] + s[span[1]:]
  103.   return s
  104.  
  105.  
  106. #-----------------------------------------------------------------------------
  107.  
  108.  
  109. def nnz_str(s):
  110.   """Strip any leading minus sign if the significand is zero.
  111.  
  112.  The Unicode minus sign U+2212 may be used as well as the usual U+0045.
  113.  """
  114.   s = str(s)
  115.   return s[1 if re_neg_zero.match(s) else 0:]
  116.  
  117.  
  118. #-----------------------------------------------------------------------------
  119.  
  120.  
  121. def max_dp_str_fn(max_dp=6, keep_dp=False, keep_zbdp=True):
  122.   """Return a function which rounds to a given number of decimal places.
  123.  
  124.  The number of decimal places, whether to always keep the decimal
  125.  point and whether to keep the a leading zero before a decimal point
  126.  is given in this function, which returns a single-argument function
  127.  via partial application.
  128.  """
  129.   if max_dp is None: max_dp = 70
  130.   return lambda x: remove_trailing_zeros(
  131.     nnz_str(f"{x:.{max_dp}f}"), keep_dp
  132.   )
  133.  
  134.  
  135. #-----------------------------------------------------------------------------
  136. # SVG generation
  137. #-----------------------------------------------------------------------------
  138.  
  139.  
  140. def length_and_unit_from_str(length_str):
  141.   """Return a (length value, unit string) tuple from a string."""
  142.   S = str(length_str)
  143.   m = re_std_decimal_number.search(S)
  144.   if m:
  145.     x = m.span(0)[1]
  146.     length = float(S[:x])
  147.     unitstr = S[x:].lstrip()
  148.   else:
  149.     length = None
  150.     unitstr = None
  151.   return length, unitstr
  152.  
  153.  
  154. #-----------------------------------------------------------------------------
  155.  
  156.  
  157. def svg_header_lines(
  158.   title,
  159.   w,
  160.   h,
  161.   pixel_shape=None,
  162.   widthstr=None,
  163.   heightstr=None
  164. ):
  165.   """Yield lines for the SVG header and code preceding the imaage.
  166.  
  167.  Arguments
  168.    title:        SVG title
  169.    w:            Number of pixels wide
  170.    h:            Number of pixels high
  171.    pixel_shape:  (w, h) tuple of pixel aspect
  172.    widthstr:     SVG length and unit for the physical width
  173.    heightstr:    SVG length and unit for the physical height
  174.  """
  175.  
  176.   mdp = max_dp_str_fn(6, keep_dp=False)
  177.   if title is None:
  178.     title = '(Untitled)'
  179.   if w is None: w = h
  180.   if h is None: h = w
  181.   if w is None: w = h = 16
  182.   w = max(1, w)
  183.   h = max(1, h)
  184.   if pixel_shape is None:
  185.     pixel_shape = (1, 1)
  186.   pixw, pixh = pixel_shape
  187.   mw = w*pixw
  188.   mh = h*pixh
  189.   wsv, wus = length_and_unit_from_str(widthstr)
  190.   hsv, hus = length_and_unit_from_str(heightstr)
  191.   if wsv:
  192.     if not hsv:
  193.       hsv = wsv * mh / mw
  194.       hus = wus
  195.   else:
  196.     if hsv:
  197.       wsv = hsv * mw / mh
  198.       wus = hus
  199.     else:
  200.       wsv = w
  201.       hsv = h
  202.       wus = hus = "px"
  203.   if wus and wus == hus:
  204.     wus = hus = html.escape(wus)
  205.   else:
  206.     wus = hus = "px"
  207.   metric_aspect = mh*pixh/(mw*pixw)
  208.   mectric_is_square = abs(metric_aspect - 1.0) < 1e-7
  209.   #print("Metric Aspect =", metric_aspect)
  210.  
  211.   ts = html.escape(title)
  212.  
  213.   yield '<?xml version="1.0" standalone="no"?>'
  214.   yield '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"'
  215.   yield '  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
  216.   yield (f'<svg width="{mdp(wsv)}{wus}" height="{mdp(hsv)}{hus}"'
  217.       f' viewBox="0 0 {mw} {mh}"')
  218.   yield '    preserveAspectRatio="none"'
  219.   yield '    xmlns="http://www.w3.org/2000/svg" version="1.1"'
  220.   yield '    xmlns:xlink="http://www.w3.org/1999/xlink">'
  221.   yield f'<title>{ts}</title>'
  222.   if pixw == pixh == 1:
  223.     yield f'<g>'
  224.   else:
  225.     yield (f'<g transform="scale({pixw}, {pixh})">')
  226.  
  227.  
  228. #-----------------------------------------------------------------------------
  229.  
  230.  
  231. def svg_footer_lines():
  232.   """Yield the footer lines of SVG code."""
  233.   yield '</g>'
  234.   yield '</svg>'
  235.  
  236.  
  237. #-----------------------------------------------------------------------------
  238.  
  239.  
  240. def svg_body_lines(width, height, pil_fmt, source_file, scaling_mode=None):
  241.   """Yield the SVG code lines for an embedded image using base64 encoding.
  242.  
  243.  Arguments
  244.  
  245.    width:        Image width in pixels
  246.    height:       Image height in pixels
  247.    pil_fmt:      Python Image Library Image format code ('PNG', 'JPEG' etc.)
  248.    source_file:  A file already opened in read-binary mode
  249.    scaling_mode  Image-rendering: auto, smooth, crisp-edges, pixelated
  250.                  (default: pixelated)
  251.  """
  252.   line_chunk_size = 3*19
  253.   read_chunk_size = line_chunk_size * 200
  254.   svg_img_fmt_str = pil_fmt.lower()
  255.   if scaling_mode is None:
  256.     scaling_mode = 'pixelated'
  257.   yield f'<image x="0" y="0" width="{width}" height="{height}"'
  258.   yield f'  style="image-rendering: {scaling_mode}"'
  259.   yield f'  xlink:href="data:image/{svg_img_fmt_str};base64,'
  260.   tail = '"/>'
  261.   chunk = source_file.read(read_chunk_size)
  262.   output_buffer = ""
  263.   while chunk:
  264.     offset = 0
  265.     line_chunk = chunk[offset : offset + line_chunk_size]
  266.     while line_chunk:
  267.       B = base64.b64encode(line_chunk)
  268.       S = B.decode()
  269.       if output_buffer:
  270.         yield output_buffer
  271.       output_buffer = "  " + S
  272.       offset += line_chunk_size
  273.       line_chunk = chunk[offset : offset + line_chunk_size]
  274.     chunk = source_file.read(read_chunk_size)
  275.   if len(output_buffer) + len(tail) <= 78:
  276.     yield output_buffer + tail
  277.   else:
  278.     yield output_buffer
  279.     yield tail
  280.  
  281.  
  282. #-----------------------------------------------------------------------------
  283.  
  284.  
  285. def get_arguments():
  286.   """Parse the command line arguments.
  287.  
  288.  If --help or -V/--version is invoked, the program will exit early.
  289.  """
  290.  
  291.   cmd = os.path.basename(sys.argv[0])
  292.  
  293.   parser = argparse.ArgumentParser(
  294.     prog=cmd,
  295.     add_help=False,
  296.     description="Embed an image into an SVG."
  297.   )
  298.  
  299.   parser.add_argument(
  300.       "--help",
  301.       dest="help", action="store_true",
  302.       help="Display this message and exit.")
  303.   parser.add_argument(
  304.       "-t", "--title", metavar="TITLE",
  305.       dest="title", type=str, action="store",
  306.       help=("SVG title"))
  307.   parser.add_argument(
  308.       "-w", "--width", metavar="WIDTH",
  309.       dest="widthstr", type=str, action="store",
  310.       help=("Physical SVG width with unit"))
  311.   parser.add_argument(
  312.       "-h", "--height", metavar="HEIGHT",
  313.       dest="heightstr", type=str, action="store",
  314.       help=("Physical SVG height with unit"))
  315.   parser.add_argument(
  316.       "-p", "--pixel", metavar="H:W",
  317.       dest="pixel_aspect", action="store",
  318.       help="Pixel aspect ratio (default 1:1)")
  319.   parser.add_argument(
  320.       "-s", "--scaling", metavar="MODE",
  321.       dest="scaling_str", type=str, default="pixelated", action="store",
  322.       help=("CSS image-rendering property: auto, smooth, "
  323.           "crisp-edges, pixelated."))
  324.   parser.add_argument(
  325.       "-V", "--version",
  326.       dest="version", action="store_true",
  327.       help="Display version and exit.")
  328.  
  329.   parser.add_argument(
  330.       "filename", metavar="IMAGE",
  331.       type=str,
  332.       help=("Image to read"))
  333.   parser.add_argument(
  334.       "outfilename", metavar="SVGFILE",
  335.       type=str,
  336.       help=("SVG file to create"))
  337.  
  338.   if "--help" in sys.argv:
  339.     parser.print_help()
  340.     sys.exit(0)
  341.  
  342.   if "-V" in sys.argv or "--version" in sys.argv:
  343.     print(VERSION)
  344.     sys.exit(0)
  345.  
  346.   args = parser.parse_args()
  347.  
  348.   return args
  349.  
  350.  
  351. #-----------------------------------------------------------------------------
  352.  
  353.  
  354. def get_pix_shape(shape_str):
  355.   """Parse the pixel aspect ratio argument.
  356.  
  357.  No attempt is to simplify the ratio (by using gcd(), say).
  358.  """
  359.   if shape_str is None:
  360.     return (1, 1)
  361.   x = 1
  362.   y = 1
  363.   s = shape_str.lower()
  364.   if ':' in s:
  365.     sep = ':'
  366.   elif ',' in s:
  367.     sep = ','
  368.   elif 'X' in s:
  369.     sep = 'X'
  370.   elif 'x' in s:
  371.     sep = 'x'
  372.   else:
  373.     sep = None
  374.   if sep is None:
  375.     try:
  376.       x = float(s)
  377.     except ValueError:
  378.       x = max(1.0/256.0, min(256.0, x))
  379.       y = 1.0
  380.   else:
  381.     if sep == ',' and len(s) >= 3 and s[0] == '(' and s[-1] == ')':
  382.       s = s[1 :  len(s) - 1]
  383.     fields = s.split(sep)
  384.     try:
  385.       x = int(fields[0])
  386.     except ValueError:
  387.       x = 1
  388.     try:
  389.       y = int(fields[1])
  390.     except ValueError:
  391.       y = 1
  392.     x = max(1, min(256, x))
  393.     y = max(1, min(256, y))
  394.   return (x, y)
  395.  
  396.  
  397. #-----------------------------------------------------------------------------
  398.  
  399.  
  400. def main():
  401.  
  402.   result = 0
  403.   err_msg = ''
  404.  
  405.   cmd = os.path.basename(sys.argv[0])
  406.  
  407.   try:
  408.  
  409.     args = get_arguments()
  410.     pixel_shape = get_pix_shape(args.pixel_aspect)
  411.     smodes = {
  412.       "a": "auto",
  413.       "s": "smooth",
  414.       "c": "crisp-edges",
  415.       "p": "pixelated",
  416.       "ce": "crisp-edges",
  417.     }
  418.     scaling_str = smodes['p']
  419.     s = args.scaling_str.lower()
  420.     if s:
  421.       if s in smodes.values():
  422.         scaling_str = s
  423.       else:
  424.         if s not in smodes.keys():
  425.           raise ArgError("Invalid scaling mode.")
  426.         scaling_str = smodes[s]
  427.  
  428.     im = Image.open(args.filename)
  429.     w = im.width
  430.     h = im.height
  431.     fmt = im.format
  432.     im.close()
  433.     del im
  434.  
  435.     with open(args.outfilename, 'w') as f:
  436.       with open(args.filename, 'rb') as source_file:
  437.         header_gen = svg_header_lines(
  438.           title = args.title,
  439.           w = w,
  440.           h = h,
  441.           pixel_shape = pixel_shape,  # Pixel shape is in (w, h) format.
  442.           widthstr = args.widthstr,
  443.           heightstr = args.heightstr,
  444.         )
  445.         body_gen = svg_body_lines(
  446.           width = w,
  447.           height = h,
  448.           pil_fmt = fmt,
  449.           source_file = source_file,
  450.           scaling_mode = scaling_str
  451.         )
  452.         for line in header_gen:
  453.           f.write(f"{line}\n")
  454.         for line in body_gen:
  455.           f.write(f"{line}\n")
  456.         for line in svg_footer_lines():
  457.           f.write(f"{line}\n")
  458.  
  459.   except ArgError as E:
  460.     err_msg = 'Error: ' + str(E)
  461.     result = 2
  462.   except FileError as E:
  463.     err_msg = str(E)
  464.     result = 3
  465.   except CmdError as E:
  466.     err_msg = str(E)
  467.     result = 4
  468.   except DataError as E:
  469.     err_msg = str(E)
  470.     result = 5
  471.   except Exception as E:
  472.     exc_type, exc_value, exc_traceback = sys.exc_info()
  473.     err_lines = traceback.format_exc().splitlines()
  474.     err_msg = 'Unhandled exception:\n' + '\n'.join(err_lines)
  475.     result = 1
  476.  
  477.   if err_msg != '':
  478.     print(cmd + ': ' + err_msg, file=sys.stderr)
  479.  
  480.   return result
  481.  
  482.  
  483. #-----------------------------------------------------------------------------
  484.  
  485.  
  486. if __name__ == '__main__':
  487.   main()
  488.  
  489.  
  490. #-----------------------------------------------------------------------------
  491. # End
  492. #-----------------------------------------------------------------------------
  493.  
Add Comment
Please, Sign In to add comment