Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/python3
- """
- ; PixinSVG - Create an SVG consisting of an embedded image
- ; Version: 1.0.0.0
- ; (c) Copyright 2025, Daniel Neville
- ; This program is free software: you can redistribute it and/or modify
- ; it under the terms of the GNU General Public License as published by
- ; the Free Software Foundation, either version 3 of the License, or
- ; (at your option) any later version.
- ;
- ; This program is distributed in the hope that it will be useful,
- ; but WITHOUT ANY WARRANTY; without even the implied warranty of
- ; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- ; GNU General Public License for more details.
- ;
- ; You should have received a copy of the GNU General Public License
- ; along with this program. If not, see <https://www.gnu.org/licenses/>.
- -----
- This script wraps SVG code around an image so that the pixelated
- scaling mode will be preserved for the benefit of T-shirt printers
- which might spoil pixel art with smooth shading.
- Example usage:
- pixinsvg -width 20cm -p 1:1 -s pixelated PizzaGlue.png PizzaGlue.svg
- """
- import os
- import sys
- import traceback
- #import math
- #import numpy as np
- import itertools
- import re
- import html
- import argparse
- from PIL import Image
- import base64
- VERSION = '1.0.0.0'
- class Error (Exception):
- pass
- class ArgError (Error):
- pass
- class FileError(Error):
- pass
- class DataError(Error):
- pass
- class CmdError(Error):
- pass
- #-----------------------------------------------------------------------------
- # Number formatting and text handling functions
- # (These should really be in a separate file.)
- #-----------------------------------------------------------------------------
- # Regular expressions
- re_std_decimal_number = fp = re.compile(
- r"^[+-]?([0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)([Ee][-+]?[0-9]+)?")
- re_trailing_zeros = re.compile(r".*?\.[0-9]+?(0+)(?:[Ee][^Ee]*)?$")
- re_decimal_zeros = re.compile(r".*?(\.0*)(?:[Ee][^Ee]*)?$")
- re_neg_zero = re.compile(
- r"^([-\u2212])(0+\.0*|0*\.0+)([Ee][+-\u2212]?[0-9]+)?$")
- #-----------------------------------------------------------------------------
- def remove_trailing_zeros(x, keep_dp=False):
- """Return a string representation of a number with trailing zeros stripped.
- The number may already be a string representation, in which case, any
- spurious trailing decimal point will be removed.
- If keep_dp is False (the default), the removal of trailing zeros extends
- to the removal of the decimal point as well.
- Numbers expressed in E notation are handled correctly.
- This function requires any decimal point to be preceded by a digit.
- """
- s = str(x)
- if m := re_trailing_zeros.match(s):
- span = m.span(1)
- s = s[:span[0]] + s[span[1]:]
- if not keep_dp:
- if m := re_decimal_zeros.match(s):
- span = m.span(1)
- s = s[:span[0]] + s[span[1]:]
- return s
- #-----------------------------------------------------------------------------
- def nnz_str(s):
- """Strip any leading minus sign if the significand is zero.
- The Unicode minus sign U+2212 may be used as well as the usual U+0045.
- """
- s = str(s)
- return s[1 if re_neg_zero.match(s) else 0:]
- #-----------------------------------------------------------------------------
- def max_dp_str_fn(max_dp=6, keep_dp=False, keep_zbdp=True):
- """Return a function which rounds to a given number of decimal places.
- The number of decimal places, whether to always keep the decimal
- point and whether to keep the a leading zero before a decimal point
- is given in this function, which returns a single-argument function
- via partial application.
- """
- if max_dp is None: max_dp = 70
- return lambda x: remove_trailing_zeros(
- nnz_str(f"{x:.{max_dp}f}"), keep_dp
- )
- #-----------------------------------------------------------------------------
- # SVG generation
- #-----------------------------------------------------------------------------
- def length_and_unit_from_str(length_str):
- """Return a (length value, unit string) tuple from a string."""
- S = str(length_str)
- m = re_std_decimal_number.search(S)
- if m:
- x = m.span(0)[1]
- length = float(S[:x])
- unitstr = S[x:].lstrip()
- else:
- length = None
- unitstr = None
- return length, unitstr
- #-----------------------------------------------------------------------------
- def svg_header_lines(
- title,
- w,
- h,
- pixel_shape=None,
- widthstr=None,
- heightstr=None
- ):
- """Yield lines for the SVG header and code preceding the imaage.
- Arguments
- title: SVG title
- w: Number of pixels wide
- h: Number of pixels high
- pixel_shape: (w, h) tuple of pixel aspect
- widthstr: SVG length and unit for the physical width
- heightstr: SVG length and unit for the physical height
- """
- mdp = max_dp_str_fn(6, keep_dp=False)
- if title is None:
- title = '(Untitled)'
- if w is None: w = h
- if h is None: h = w
- if w is None: w = h = 16
- w = max(1, w)
- h = max(1, h)
- if pixel_shape is None:
- pixel_shape = (1, 1)
- pixw, pixh = pixel_shape
- mw = w*pixw
- mh = h*pixh
- wsv, wus = length_and_unit_from_str(widthstr)
- hsv, hus = length_and_unit_from_str(heightstr)
- if wsv:
- if not hsv:
- hsv = wsv * mh / mw
- hus = wus
- else:
- if hsv:
- wsv = hsv * mw / mh
- wus = hus
- else:
- wsv = w
- hsv = h
- wus = hus = "px"
- if wus and wus == hus:
- wus = hus = html.escape(wus)
- else:
- wus = hus = "px"
- metric_aspect = mh*pixh/(mw*pixw)
- mectric_is_square = abs(metric_aspect - 1.0) < 1e-7
- #print("Metric Aspect =", metric_aspect)
- ts = html.escape(title)
- yield '<?xml version="1.0" standalone="no"?>'
- yield '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"'
- yield ' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
- yield (f'<svg width="{mdp(wsv)}{wus}" height="{mdp(hsv)}{hus}"'
- f' viewBox="0 0 {mw} {mh}"')
- yield ' preserveAspectRatio="none"'
- yield ' xmlns="http://www.w3.org/2000/svg" version="1.1"'
- yield ' xmlns:xlink="http://www.w3.org/1999/xlink">'
- yield f'<title>{ts}</title>'
- if pixw == pixh == 1:
- yield f'<g>'
- else:
- yield (f'<g transform="scale({pixw}, {pixh})">')
- #-----------------------------------------------------------------------------
- def svg_footer_lines():
- """Yield the footer lines of SVG code."""
- yield '</g>'
- yield '</svg>'
- #-----------------------------------------------------------------------------
- def svg_body_lines(width, height, pil_fmt, source_file, scaling_mode=None):
- """Yield the SVG code lines for an embedded image using base64 encoding.
- Arguments
- width: Image width in pixels
- height: Image height in pixels
- pil_fmt: Python Image Library Image format code ('PNG', 'JPEG' etc.)
- source_file: A file already opened in read-binary mode
- scaling_mode Image-rendering: auto, smooth, crisp-edges, pixelated
- (default: pixelated)
- """
- line_chunk_size = 3*19
- read_chunk_size = line_chunk_size * 200
- svg_img_fmt_str = pil_fmt.lower()
- if scaling_mode is None:
- scaling_mode = 'pixelated'
- yield f'<image x="0" y="0" width="{width}" height="{height}"'
- yield f' style="image-rendering: {scaling_mode}"'
- yield f' xlink:href="data:image/{svg_img_fmt_str};base64,'
- tail = '"/>'
- chunk = source_file.read(read_chunk_size)
- output_buffer = ""
- while chunk:
- offset = 0
- line_chunk = chunk[offset : offset + line_chunk_size]
- while line_chunk:
- B = base64.b64encode(line_chunk)
- S = B.decode()
- if output_buffer:
- yield output_buffer
- output_buffer = " " + S
- offset += line_chunk_size
- line_chunk = chunk[offset : offset + line_chunk_size]
- chunk = source_file.read(read_chunk_size)
- if len(output_buffer) + len(tail) <= 78:
- yield output_buffer + tail
- else:
- yield output_buffer
- yield tail
- #-----------------------------------------------------------------------------
- def get_arguments():
- """Parse the command line arguments.
- If --help or -V/--version is invoked, the program will exit early.
- """
- cmd = os.path.basename(sys.argv[0])
- parser = argparse.ArgumentParser(
- prog=cmd,
- add_help=False,
- description="Embed an image into an SVG."
- )
- parser.add_argument(
- "--help",
- dest="help", action="store_true",
- help="Display this message and exit.")
- parser.add_argument(
- "-t", "--title", metavar="TITLE",
- dest="title", type=str, action="store",
- help=("SVG title"))
- parser.add_argument(
- "-w", "--width", metavar="WIDTH",
- dest="widthstr", type=str, action="store",
- help=("Physical SVG width with unit"))
- parser.add_argument(
- "-h", "--height", metavar="HEIGHT",
- dest="heightstr", type=str, action="store",
- help=("Physical SVG height with unit"))
- parser.add_argument(
- "-p", "--pixel", metavar="H:W",
- dest="pixel_aspect", action="store",
- help="Pixel aspect ratio (default 1:1)")
- parser.add_argument(
- "-s", "--scaling", metavar="MODE",
- dest="scaling_str", type=str, default="pixelated", action="store",
- help=("CSS image-rendering property: auto, smooth, "
- "crisp-edges, pixelated."))
- parser.add_argument(
- "-V", "--version",
- dest="version", action="store_true",
- help="Display version and exit.")
- parser.add_argument(
- "filename", metavar="IMAGE",
- type=str,
- help=("Image to read"))
- parser.add_argument(
- "outfilename", metavar="SVGFILE",
- type=str,
- help=("SVG file to create"))
- if "--help" in sys.argv:
- parser.print_help()
- sys.exit(0)
- if "-V" in sys.argv or "--version" in sys.argv:
- print(VERSION)
- sys.exit(0)
- args = parser.parse_args()
- return args
- #-----------------------------------------------------------------------------
- def get_pix_shape(shape_str):
- """Parse the pixel aspect ratio argument.
- No attempt is to simplify the ratio (by using gcd(), say).
- """
- if shape_str is None:
- return (1, 1)
- x = 1
- y = 1
- s = shape_str.lower()
- if ':' in s:
- sep = ':'
- elif ',' in s:
- sep = ','
- elif 'X' in s:
- sep = 'X'
- elif 'x' in s:
- sep = 'x'
- else:
- sep = None
- if sep is None:
- try:
- x = float(s)
- except ValueError:
- x = max(1.0/256.0, min(256.0, x))
- y = 1.0
- else:
- if sep == ',' and len(s) >= 3 and s[0] == '(' and s[-1] == ')':
- s = s[1 : len(s) - 1]
- fields = s.split(sep)
- try:
- x = int(fields[0])
- except ValueError:
- x = 1
- try:
- y = int(fields[1])
- except ValueError:
- y = 1
- x = max(1, min(256, x))
- y = max(1, min(256, y))
- return (x, y)
- #-----------------------------------------------------------------------------
- def main():
- result = 0
- err_msg = ''
- cmd = os.path.basename(sys.argv[0])
- try:
- args = get_arguments()
- pixel_shape = get_pix_shape(args.pixel_aspect)
- smodes = {
- "a": "auto",
- "s": "smooth",
- "c": "crisp-edges",
- "p": "pixelated",
- "ce": "crisp-edges",
- }
- scaling_str = smodes['p']
- s = args.scaling_str.lower()
- if s:
- if s in smodes.values():
- scaling_str = s
- else:
- if s not in smodes.keys():
- raise ArgError("Invalid scaling mode.")
- scaling_str = smodes[s]
- im = Image.open(args.filename)
- w = im.width
- h = im.height
- fmt = im.format
- im.close()
- del im
- with open(args.outfilename, 'w') as f:
- with open(args.filename, 'rb') as source_file:
- header_gen = svg_header_lines(
- title = args.title,
- w = w,
- h = h,
- pixel_shape = pixel_shape, # Pixel shape is in (w, h) format.
- widthstr = args.widthstr,
- heightstr = args.heightstr,
- )
- body_gen = svg_body_lines(
- width = w,
- height = h,
- pil_fmt = fmt,
- source_file = source_file,
- scaling_mode = scaling_str
- )
- for line in header_gen:
- f.write(f"{line}\n")
- for line in body_gen:
- f.write(f"{line}\n")
- for line in svg_footer_lines():
- f.write(f"{line}\n")
- except ArgError as E:
- err_msg = 'Error: ' + str(E)
- result = 2
- except FileError as E:
- err_msg = str(E)
- result = 3
- except CmdError as E:
- err_msg = str(E)
- result = 4
- except DataError as E:
- err_msg = str(E)
- result = 5
- except Exception as E:
- exc_type, exc_value, exc_traceback = sys.exc_info()
- err_lines = traceback.format_exc().splitlines()
- err_msg = 'Unhandled exception:\n' + '\n'.join(err_lines)
- result = 1
- if err_msg != '':
- print(cmd + ': ' + err_msg, file=sys.stderr)
- return result
- #-----------------------------------------------------------------------------
- if __name__ == '__main__':
- main()
- #-----------------------------------------------------------------------------
- # End
- #-----------------------------------------------------------------------------
Add Comment
Please, Sign In to add comment