Import of the watch repository from Pebble

This commit is contained in:
Matthieu Jeanson
2024-12-12 16:43:03 -08:00
committed by Katharine Berry
commit 3b92768480
10334 changed files with 2564465 additions and 0 deletions

View File

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
"""executed when directory is called as script."""
from pblconvert import main
main()

View File

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python
from __future__ import print_function
import os
import platform
import sys
import subprocess
arguments = sys.argv[1:]
THIS_PATH = os.path.abspath(__file__)
LINUX_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'gif2apng_noprev_linux')
OSX_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'gif2apng_noprev_osx')
if platform.system() == 'Darwin':
cmd = [OSX_PATH]
elif platform.system() == 'Linux':
cmd = [LINUX_PATH]
else:
raise Exception("Your operating system is not supported")
# Transform absolute paths into relative paths (they are not supported)
arguments = [a if a[0] != '/' else os.path.relpath(a, THIS_PATH) for a in arguments]
cmd += arguments
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate()
print(out, file=sys.stdout)
print(err, file=sys.stderr)
sys.exit(p.returncode)

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python
import os, sys
from ctypes import *
from PIL import Image
format_dict = {
'GBitmapFormat1Bit': 0,
'GBitmapFormat8Bit': 1,
'GBitmapFormat1BitPalette': 2,
'GBitmapFormat2BitPalette': 3,
'GBitmapFormat4BitPalette': 4
}
# NOTE: If this changes, please update the GBitmapDump gdb command.
class pbi_struct(Structure):
_fields_ = [
("stride", c_uint16), ("info", c_uint16),
("bounds_x", c_uint16), ("bounds_y", c_uint16),
("bounds_w", c_uint16), ("bounds_h", c_uint16),
]
def flip_byte(abyte):
return int('{:08b}'.format(abyte)[::-1],2)
#converts from argb8 (2-bits per color channel) to RGBA32 (byte per channel)
def argb8_to_rgba32(argb8):
return (
((argb8 >> 4) & 0x3) * 85, #R
((argb8 >> 2) & 0x3) * 85, #G
((argb8 ) & 0x3) * 85, #B
((argb8 >> 6) & 0x3) * 85) #A
def pbi_format(info):
return (info & 0xe) >> 1
def pbi_bitdepth(fmt):
bitdepth_list = [1, 8, 1, 2, 4]
return bitdepth_list[fmt]
def pbi_is_palettized(fmt):
return fmt >= format_dict['GBitmapFormat1BitPalette']
def palette_size(fmt):
return 2 ** pbi_bitdepth(fmt)
def pbi_to_png(pbi, pixel_bytearray):
gbitmap_version = (pbi.info >> 12) & 0x0F
gbitmap_format = pbi_format(pbi.info)
# if version is 2 and format is 0x01 (GBitmapFormat8Bit)
if gbitmap_version == 1 and gbitmap_format == format_dict['GBitmapFormat8Bit']:
print("8-bit ARGB color image")
pixel_rgba_array = bytearray()
for (idx, abyte) in enumerate(pixel_bytearray):
argb8 = pixel_bytearray[idx]
pixel_rgba_array.append(((argb8 >> 4) & 0x3) * 85) # r
pixel_rgba_array.append(((argb8 >> 2) & 0x3) * 85) # g
pixel_rgba_array.append(((argb8 >> 0) & 0x3) * 85) # b
pixel_rgba_array.append(((argb8 >> 6) & 0x3) * 85) # a
png = Image.frombuffer('RGBA', (pbi.bounds_w, pbi.bounds_h),
buffer(pixel_rgba_array), 'raw', 'RGBA', pbi.stride * 4, 1)
elif gbitmap_version == 1 and pbi_is_palettized(gbitmap_format):
bitdepth = pbi_bitdepth(gbitmap_format)
print("{}-bit palettized color image".format(bitdepth))
# Create palette colors in format R, G, B, A
palette = []
palette_offset = pbi.stride * pbi.bounds_h
for argb8 in pixel_bytearray[palette_offset:]:
palette.append((argb8_to_rgba32(argb8)))
pixels = []
# go through the image data byte by byte, and handle
# converting the depth-packed indexes for the palette to an unpacked list
idx = 0 # index of actual packed values including padded values
for pxl8 in pixel_bytearray[:palette_offset]:
for i in xrange(0, 8 / bitdepth):
# only append actual pixels, ignoring padding pixels
# which is the difference between the width and the stride
if (idx % (pbi.stride * (8 / bitdepth)) < pbi.bounds_w):
pixels.append(
((pxl8 >> (bitdepth * (8 / bitdepth - (i + 1)))) & ~(~0 << bitdepth)))
idx = idx + 1
# Manually convert from paletted to RGBA
# as PIL doesn't seem to handle palette with alpha
rgba_pixels = []
for pal_pxl in pixels:
rgba_pixels.append(palette[pal_pxl])
png = Image.new('RGBA', (pbi.bounds_w, pbi.bounds_h))
png.putdata(rgba_pixels)
# legacy 1-bit format
elif gbitmap_version == 0 or \
(gbitmap_version == 1 and gbitmap_format == format_dict['GBitmapFormat1Bit']):
print("1-bit b&w image")
# pbi has bits in bytes reversed, so flip here
for (idx, abyte) in enumerate(pixel_bytearray):
pixel_bytearray[idx] = flip_byte(pixel_bytearray[idx])
png = Image.frombuffer('1', (pbi.bounds_w, pbi.bounds_h),
buffer(pixel_bytearray), 'raw', '1', pbi.stride, 1)
else:
print "Bad PBI"
png = None
return png
def main():
# arguments, print an example of correct usage.
if len(sys.argv) - 1 != 2:
print("********************")
print("Usage suggestion:")
print("python " + sys.argv[0] + " <in_image.pbi> <out_image.png>")
print("********************")
exit()
input_filename = sys.argv[1]
output_filename = sys.argv[2]
print("Converting PBI to PNG...")
pbi = pbi_struct()
pixel_bytearray = bytearray()
with open(input_filename, 'rb') as afile:
afile.readinto(pbi)
print("x:%d y:%d" % (pbi.bounds_x, pbi.bounds_y))
print("Width:%d Height:%d" % (pbi.bounds_w, pbi.bounds_h))
print("row stride:%d" % (pbi.stride))
pixel_bytearray = bytearray(afile.read())
png = pbi_to_png(pbi, pixel_bytearray)
png.save(output_filename)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python
from __future__ import print_function
import os
import platform
import sys
import subprocess
arguments = sys.argv[1:]
LINUX_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pdc2png_linux')
OSX_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pdc2png_osx')
if platform.system() == 'Darwin':
cmd = [OSX_PATH]
elif platform.system() == 'Linux':
cmd = [LINUX_PATH]
else:
raise Exception("Your operating system is not supported")
cmd.extend(arguments)
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate()
print(out, file=sys.stdout)
print(err, file=sys.stderr)
sys.exit(p.returncode)

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,6 @@
class PblConvertError(Exception):
def __str__(self):
return self.__class__.__name__ + ': ' + ' '.join(self.args)
class PblConvertFormatError(PblConvertError):
pass

View File

@@ -0,0 +1,68 @@
; The colormap for the Pebble Time family watches
; in a format gifsicle understands.
; See https://www.lcdf.org/gifsicle/man.html
; for more details on the --user-colormap flag
0 0 0
0 0 85
0 0 170
0 0 255
0 85 0
0 85 85
0 85 170
0 85 255
0 170 0
0 170 85
0 170 170
0 170 255
0 255 0
0 255 85
0 255 170
0 255 255
85 0 0
85 0 85
85 0 170
85 0 255
85 85 0
85 85 85
85 85 170
85 85 255
85 170 0
85 170 85
85 170 170
85 170 255
85 255 0
85 255 85
85 255 170
85 255 255
170 0 0
170 0 85
170 0 170
170 0 255
170 85 0
170 85 85
170 85 170
170 85 255
170 170 0
170 170 85
170 170 170
170 170 255
170 255 0
170 255 85
170 255 170
170 255 255
255 0 0
255 0 85
255 0 170
255 0 255
255 85 0
255 85 85
255 85 170
255 85 255
255 170 0
255 170 85
255 170 170
255 170 255
255 255 0
255 255 85
255 255 170
255 255 255

View File

@@ -0,0 +1,7 @@
class Gif2ApngError(Exception):
def __str__(self):
return self.__class__.__name__ + ': ' + ' '.join(self.args)
class Gif2ApngFormatError(Gif2ApngError):
pass

View File

@@ -0,0 +1,69 @@
from __future__ import print_function
import imghdr
import io
import os
import subprocess
import sys
import tempfile
# gif2apng
from exceptions import *
GIF2APNG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'../bin/gif2apng')
COLORMAP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'colormap.txt')
def read_gif(obj):
data = obj.read()
if imghdr.what(None, data) != 'gif':
raise Gif2ApngFormatError("{} is not a valid GIF data".format(path))
return data
def convert_to_apng(gif):
# Write data to temporary file
gif_file = tempfile.NamedTemporaryFile(delete=False)
gif_file.write(gif)
gif_file.close()
# Map onto Pebble colors
mod_file = tempfile.NamedTemporaryFile(delete=False)
mod_file.close()
p = subprocess.Popen(['gifsicle',
'--colors', '64',
'--use-colormap', COLORMAP_PATH,
'-O1',
'-o', mod_file.name,
gif_file.name],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate()
# Deal with https://github.com/kohler/gifsicle/issues/28
# Which still exists in some of the packages out there
if p.returncode not in [0, 1]:
print(err, file=sys.stderr)
raise Gif2ApngError(p.returncode)
# Convert to APNG
apng_file = tempfile.NamedTemporaryFile(delete=False)
apng_file.close()
p = subprocess.Popen([GIF2APNG_PATH,
'-z0',
mod_file.name,
apng_file.name],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate()
if p.returncode != 0:
print(err, file=sys.stderr)
raise Gif2ApngError(p.returncode)
with open(apng_file.name) as f:
apng_data = f.read()
os.unlink(gif_file.name)
os.unlink(mod_file.name)
os.unlink(apng_file.name)
return apng_data

View File

@@ -0,0 +1,112 @@
from abc import *
from exceptions import PblConvertFormatError
from gif2apng.gif import read_gif, convert_to_apng
from gif2apng.exceptions import Gif2ApngFormatError
from svg2pdc.pdc import serialize_image, convert_to_png
from svg2pdc.svg import surface_from_svg
from svg2pdc.exceptions import Svg2PdcFormatError
class Handler:
__metaclass__ = ABCMeta
@classmethod
def handler_for_format(cls, fmt):
if cls is Handler:
for C in cls.__subclasses__():
if fmt == C.format():
return C()
return None
raise NotImplementedError
@abstractmethod
def read(self, in_obj):
return None
@abstractmethod
def format(self):
return ""
@abstractmethod
def write_pdc(self, out_obj, data):
return None
@abstractmethod
def write_apng(self, out_obj, data):
return None
@abstractmethod
def write_png(self, out_obj, data):
return None
@abstractmethod
def write_svg(self, out_obj, data):
return None
class SvgHandler(Handler):
@classmethod
def format(cls):
return "svg"
def read(self, in_obj):
try:
surface = surface_from_svg(bytestring=in_obj.read(),
approximate_bezier=True)
except Svg2PdcFormatError as e:
raise PblConvertFormatError(e.args[0])
return surface
def write_apng(self, out_obj, data):
raise NotImplementedError
def write_pdc(self, out_obj, surface):
commands = surface.pdc_commands
pdci = serialize_image(commands, surface.size())
with out_obj as o:
o.write(pdci)
def write_png(self, out_obj, surface):
commands = surface.pdc_commands
pdci = serialize_image(commands, surface.size())
with out_obj as o:
o.write(convert_to_png(pdci))
def write_svg(self, out_obj, surface):
with out_obj as o:
et = surface.element_tree()
et.write(o, pretty_print=True)
class GifHandler(Handler):
@classmethod
def format(cls):
return "gif"
def read(self, in_obj):
try:
gif = read_gif(in_obj)
except Gif2ApngFormatError as e:
raise PblConvertFormatError(e.args[0])
return gif
def write_pdc(self, out_obj, data):
raise NotImplementedError
def write_svg(self, out_obj, data):
raise NotImplementedError
def write_png(self, out_obj, gif):
raise NotImplementedError
def write_apng(self, out_obj, gif):
apng_data = convert_to_apng(gif)
with out_obj as o:
o.write(apng_data)
Handler.register(GifHandler)
Handler.register(SvgHandler)

View File

@@ -0,0 +1,90 @@
from handlers import *
import argparse
import os
import sys
SUPPORTED_FORMATS_MAP = {
"in": {
"gif": ".gif",
"svg": ".svg",
},
"out": {
"pdc": ".pdc",
"png": ".png",
"svg": ".svg",
"apng": ".apng",
}
}
FORMAT_TO_EXT = dict(SUPPORTED_FORMATS_MAP["in"],
**SUPPORTED_FORMATS_MAP["out"])
EXT_TO_FORMAT = {v: k for k, v in FORMAT_TO_EXT.items()}
OUT_FORMATS = SUPPORTED_FORMATS_MAP["out"].keys()
IN_FORMATS = SUPPORTED_FORMATS_MAP["in"].keys()
LIMIT_WHEN_AVOIDING_OVERRIDE = 100
def parse_args(args):
parser = argparse.ArgumentParser()
parser.add_argument('-i', '--infile',
type=argparse.FileType('r'), required=True)
parser.add_argument('-if', '--informat',
type=str, choices=IN_FORMATS)
parser.add_argument('-o', '--outfile',
type=argparse.FileType('w'))
parser.add_argument('-of', '--outformat',
type=str, choices=OUT_FORMATS)
parsed = parser.parse_args(args)
assert parsed.infile is not None
assert parsed.outformat is not None or parsed.outfile is not None
if parsed.informat is None:
parsed.informat = EXT_TO_FORMAT.get(
os.path.splitext(parsed.infile.name)[1], "svg")
if parsed.outformat is None:
parsed.outformat = "pdc" if parsed.outfile is None else \
EXT_TO_FORMAT.get(os.path.splitext(parsed.outfile.name)[1], "pdc")
if parsed.outfile is None:
if parsed.infile == sys.stdin:
parsed.outfile = sys.stdout
else:
# look at format
outfile_path = os.path.splitext(parsed.infile.name)[0] + \
FORMAT_TO_EXT[parsed.outformat]
if os.path.exists(outfile_path):
avoiding_path = None
# avoid accidental overrides
for i in range(2, LIMIT_WHEN_AVOIDING_OVERRIDE):
split = os.path.splitext(outfile_path)
avoiding_path = "%s_%d%s" % (split[0], i, split[1])
if not os.path.exists(avoiding_path):
outfile_path = avoiding_path
break
if outfile_path != avoiding_path:
raise IOError("File %s and (%d similar alternatives) "
"already exists" %
(outfile_path, LIMIT_WHEN_AVOIDING_OVERRIDE))
parsed.outfile = open(outfile_path, "w")
return parsed
def logic(handler, parsed):
data = handler.read(parsed.infile)
method = getattr(handler, "write_" + parsed.outformat)
method(parsed.outfile, data)
def main():
parsed = parse_args(sys.argv[1:])
handler = Handler.handler_for_format(parsed.informat)
logic(handler, parsed)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,65 @@
# coding=utf-8
from xml.etree import ElementTree
from pdc import extend_bounding_box, bounding_box_around_points
NS_ANNOTATION = "http://www.pebble.com/2015/pdc"
PREFIX_ANNOTATION = "pdc"
TAG_HIGHLIGHT = "{%s}highlight" % NS_ANNOTATION
TAG_ANNOTATION = "{%s}annotation" % NS_ANNOTATION
def to_str(value):
return "%.2f" % float(value)
class Annotation():
def __init__(self, node, text, transformed=False, link=None):
self.node = node
self.text = text
self.href = link
self.element = ElementTree.SubElement(self.node.node, TAG_ANNOTATION)
if text is not None:
self.element.set("description", text)
if link is not None:
self.element.set("href", link)
self.highlights = []
self.transformed = transformed
self.node.annotations.append(self)
def add_highlight(self, x, y, width=None, height=None, details=None):
highlight = ElementTree.SubElement(self.element, TAG_HIGHLIGHT)
self.set_highlight(highlight, x=x, y=y, width=width, height=height, details=details)
self.highlights.append(highlight)
return highlight
def set_highlight(self, highlight, **kwargs):
for k, v in kwargs.iteritems():
if v is None:
if k in highlight:
highlight.pop(k)
else:
value = to_str(v) if k in ["x", "y", "width", "height"] else v
highlight.set(k, value)
def transform(self, transformer):
if self.transformed:
return
self.transformed = True
for highlight in self.highlights:
top_left = (float(highlight.get("x")), float(highlight.get("y")))
size = (float(highlight.get("width")), float(highlight.get("height")))
top_right = (top_left[0] + size[0], top_left[1])
bottom_right = (top_left[0] + size[0], top_left[1] + size[1])
bottom_left = (top_left[0], top_left[1] + size[1])
# we needs to transform all four points instead of origin + diagonal
# e.g. 45° on a square would otherwise become a line
top_left = transformer.transform_point(top_left)
top_right = transformer.transform_point(top_right)
bottom_right = transformer.transform_point(bottom_right)
bottom_left = transformer.transform_point(bottom_left)
box = bounding_box_around_points([top_left, top_right, bottom_right, bottom_left])
self.set_highlight(highlight, x=box[0], y=box[1], width=box[2], height=box[3])

View File

@@ -0,0 +1,6 @@
class Svg2PdcError(Exception):
def __str__(self):
return self.__class__.__name__ + ': ' + ' '.join(self.args)
class Svg2PdcFormatError(Svg2PdcError):
pass

View File

@@ -0,0 +1,303 @@
from StringIO import StringIO
import os
import shutil
import tempfile
from struct import pack
import sys
from subprocess import Popen, PIPE
from pebble_image_routines import truncate_color_to_pebble64_palette, nearest_color_to_pebble64_palette, \
rgba32_triplet_to_argb8
DRAW_COMMAND_VERSION = 1
DRAW_COMMAND_TYPE_PATH = 1
DRAW_COMMAND_TYPE_CIRCLE = 2
DRAW_COMMAND_TYPE_PRECISE_PATH = 3
epsilon = sys.float_info.epsilon
def valid_color(r, g, b, a):
return (r <= 0xFF) and (g <= 0xFF) and (b <= 0xFF) and (a <= 0xFF) and \
(r >= 0x00) and (g >= 0x00) and (b >= 0x00) and (a >= 0x00)
def convert_color(r, g, b, a, truncate=True):
valid = valid_color(r, g, b, a)
if not valid:
print "Invalid color: ({}, {}, {}, {})".format(r, g, b, a)
return 0
if truncate:
(r, g, b, a) = truncate_color_to_pebble64_palette(r, g, b, a)
else:
(r, g, b, a) = nearest_color_to_pebble64_palette(r, g, b, a)
return rgba32_triplet_to_argb8(r, g, b, a)
def sum_points(p1, p2):
return p1[0] + p2[0], p1[1] + p2[1]
def subtract_points(p1, p2):
return p1[0] - p2[0], p1[1] - p2[1]
def round_point(p):
return round(p[0] + epsilon), round(p[1] + epsilon) # hack to get around the fact that python rounds negative
# numbers downwards
def scale_point(p, factor):
return p[0] * factor, p[1] * factor
def find_nearest_valid_point(p):
return (round(p[0] * 2.0) / 2.0), (round(p[1] * 2.0) / 2.0)
def find_nearest_valid_precise_point(p):
return (round(p[0] * 8.0) / 8.0), (round(p[1] * 8.0) / 8.0)
def convert_to_pebble_coordinates(point, precise=False):
# convert from graphic tool coordinate system to pebble coordinate system so that they render the same on
# both
if not precise:
nearest = find_nearest_valid_point(point) # used to give feedback to user if the point shifts considerably
else:
nearest = find_nearest_valid_precise_point(point)
problem = None if compare_points(point, nearest) else "Invalid point: ({:.2f}, {:.2f}). Used closest supported coordinate: ({}, {})".format(
point[0], point[1], nearest[0], nearest[1])
translated = sum_points(point, (-0.5, -0.5)) # translate point by (-0.5, -0.5)
if precise:
translated = scale_point(translated, 8) # scale point for precise coordinates
rounded = round_point(translated)
return rounded, problem
def compare_points(p1, p2):
return p1[0] == p2[0] and p1[1] == p2[1]
class InvalidPointException(Exception):
pass
def bounding_box_around_points(points):
result = None
for p in points:
result = extend_bounding_box(result, p)
return result
def extend_bounding_box(rect, point=None, rect2=None):
if rect is None:
return rect2 if rect2 is not None else (point[0], point[1], 0, 0)
if rect2 is not None:
top_left = (rect2[0], rect2[1])
bottom_right = (rect2[0] + rect2[2], rect2[1] + rect2[3])
rect = extend_bounding_box(rect, point=top_left)
rect = extend_bounding_box(rect, point=bottom_right)
return rect
assert point is not None
min_x = min(rect[0], point[0])
min_y = min(rect[1], point[1])
max_x = max(rect[0] + rect[2], point[0])
max_y = max(rect[1] + rect[3], point[1])
return (min_x, min_y, max_x - min_x, max_y - min_y)
PDC2PNG = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../bin/pdc2png")
def convert_to_png(pdc_data):
tmp_dir = tempfile.mkdtemp()
try:
pdc_path = os.path.join(tmp_dir, "image.pdc")
with open(pdc_path, "wb") as pdc_file:
pdc_file.write(pdc_data)
cmd = '%s %s' % (PDC2PNG, pdc_path)
p = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE)
stdout, stderr = p.communicate()
if p.returncode != 0:
raise IOError(stderr)
png_path = os.path.join(tmp_dir, "image.png")
with open(png_path, "rb") as png_file:
return png_file.read()
finally:
shutil.rmtree(tmp_dir)
class Command:
'''
Draw command serialized structure:
| Bytes | Field
| 1 | Draw command type
| 1 | Reserved byte
| 1 | Stroke color
| 1 | Stroke width
| 1 | Fill color
For Paths:
| 1 | Open path
| 1 | Unused/Reserved
For Circles:
| 2 | Radius
Common:
| 2 | Number of points (should always be 1 for circles)
| n * 4 | Array of n points in the format below:
Point:
| 2 | x
| 2 | y
'''
def __init__(self, points, stroke_width=0, stroke_color=0, fill_color=0,
raise_error=False):
# for i in range(len(points)):
# points[i], valid = convert_to_pebble_coordinates(points[i], precise)
# if not valid and raise_error:
# raise InvalidPointException("Invalid point in command")
self.points = list(points)
self.stroke_width = stroke_width
self.stroke_color = stroke_color
self.fill_color = fill_color
def is_precise(self):
return False
def transform(self, transformer):
self.points = list([transformer.transform_point(p) for p in self.points])
def finalize(self, annotator):
grid_annotation = None
for p in self.points:
converted, problem = convert_to_pebble_coordinates(p, self.is_precise())
if problem is not None:
if grid_annotation is None:
link = "https://pebbletechnology.atlassian.net/wiki/display/DEV/Pebble+Draw+Commands#PebbleDrawCommands-issue-pixelgrid"
grid_annotation = annotator.add_annotation("Element is expressed with unsupported coordinate(s).", link=link)
grid_annotation.add_highlight(p[0], p[1], details=problem)
pass
def bounding_box(self):
result = None
for p in self.points:
result = extend_bounding_box(result, point=p)
return result
def serialize_common(self):
return pack('<BBBB',
0, #reserved byte
self.stroke_color,
self.stroke_width,
self.fill_color)
def serialize_points(self):
s = pack('H', len(self.points)) # number of points (16-bit)
for p in self.points:
converted, _ = convert_to_pebble_coordinates(p, self.is_precise())
s += pack('<hh',
int(converted[0]), # x (16-bit)
int(converted[1])) # y (16-bit)
return s
class PathCommand(Command):
def __init__(self, points, path_open, stroke_width=0, stroke_color=0, fill_color=0, precise=False,
raise_error=False):
self.open = path_open
self.type = DRAW_COMMAND_TYPE_PATH if not precise else DRAW_COMMAND_TYPE_PRECISE_PATH
Command.__init__(self, points, stroke_width, stroke_color, fill_color, raise_error)
def is_precise(self):
return self.type == DRAW_COMMAND_TYPE_PRECISE_PATH
def serialize(self):
s = pack('B', self.type) # command type
s += self.serialize_common()
s += pack('<BB',
int(self.open), # open path boolean
0) # unused byte in path
s += self.serialize_points()
return s
def __str__(self):
points = self.points[:]
if self.type == DRAW_COMMAND_TYPE_PRECISE_PATH:
type = 'P'
for i in range(len(points)):
points[i] = scale_point(points[i], 0.125)
else:
type = ''
return "Path: [fill color:{}; stroke color:{}; stroke width:{}] {} {} {}".format(self.fill_color,
self.stroke_color,
self.stroke_width,
points,
self.open,
type)
class CircleCommand(object, Command):
def __init__(self, center, radius, stroke_width=0, stroke_color=0, fill_color=0):
points = [(center[0], center[1])]
Command.__init__(self, points, stroke_width, stroke_color, fill_color)
self.radius = radius
def transform(self, transformer):
super(CircleCommand, self).transform(transformer)
(dx, dy) = transformer.transform_distance(self.radius, self.radius)
self.radius = min(dx, dy)
if dx != dy:
annotation = transformer.add_annotation("Only rigid transformations for circles are supported.",
transformed=True)
center = self.points[0]
annotation.add_highlight(center[0] - dx, center[1] - dy, dx * 2, dy * 2)
def serialize(self):
s = pack('B', DRAW_COMMAND_TYPE_CIRCLE) # command type
s += self.serialize_common()
s += pack('H', self.radius) # circle radius (16-bit)
s += self.serialize_points()
return s
def __str__(self):
return "Circle: [fill color:{}; stroke color:{}; stroke width:{}] {} {}".format(self.fill_color,
self.stroke_color,
self.stroke_width,
self.points[0],
self.radius)
def serialize_header(size):
return pack('<BBhh', DRAW_COMMAND_VERSION, 0, int(round(size[0])), int(round(size[1])))
def serialize(commands):
output = pack('H', len(commands)) # number of commands in list
for c in commands:
output += c.serialize()
return output
def serialize_image(commands, size):
s = serialize_header(size)
s += serialize(commands)
output = "PDCI"
output += pack('I', len(s))
output += s
return output

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python
import math
# This module contains common image and color routines used to convert images
# for use with Pebble.
#
# pebble64 refers to the color palette that is available in color products,
# pebble2 refers to the palette available in b&w products
# Create pebble 64 colors-table (r, g, b - 2 bits per channel)
def _get_pebble64_palette():
pebble_palette = []
for i in xrange(0, 64):
pebble_palette.append((
((i >> 4) & 0x3) * 85, # R
((i >> 2) & 0x3) * 85, # G
((i ) & 0x3) * 85)) # B
return pebble_palette
def nearest_color_to_pebble64_palette(r, g, b, a):
"""
match each rgba32 pixel to the nearest color in the 8 bit pebble palette
returns closest rgba32 color triplet (r, g, b, a)
"""
a = ((a + 42) / 85) * 85 # fast nearest alpha for 2bit color range
# clear transparent pixels (makes image more compress-able)
# and required for greyscale tests
if a == 0:
r, g, b = (0, 0, 0)
else:
r = ((r + 42) / 85) * 85 # nearest for 2bit color range
g = ((g + 42) / 85) * 85 # nearest for 2bit color range
b = ((b + 42) / 85) * 85 # nearest for 2bit color range
return r, g, b, a
def nearest_color_to_pebble2_palette(r, g, b, a):
"""
match each rgba32 pixel to the nearest color in 2 bit pebble palette
returns closest rgba32 color triplet (r, g, b, a)
"""
# these constants come from ITU-R recommendation BT.709
luma = (r * 0.2126 + g * 0.7152 + b * 0.11)
def round_to_1_bit(value):
""" Round a [0-255] value to either 0 or 255 """
if value > (255 / 2):
return 255
return 0
rounded_luma = round_to_1_bit(luma)
return (rounded_luma, rounded_luma, rounded_luma, round_to_1_bit(a))
def truncate_color_to_pebble64_palette(r, g, b, a):
"""
converts each rgba32 pixel to the next lower matching color (truncate method)
in the pebble palette
returns the truncated color as a rgba32 color triplet (r, g, b, a)
"""
a = (a / 85) * 85 # truncate alpha for 2bit color range
# clear transparent pixels (makes image more compress-able)
# and required for greyscale tests
if a == 0:
r, g, b = (0, 0, 0)
else:
r = (r / 85) * 85 # truncate for 2bit color range
g = (g / 85) * 85 # truncate for 2bit color range
b = (b / 85) * 85 # truncate for 2bit color range
return r, g, b, a
def truncate_color_to_pebble2_palette(r, g, b, a):
"""
converts each rgba32 pixel to the next lower matching color (truncate method)
returns closest rgba32 color triplet (r, g, b, a)
"""
if a != 255:
a = 0
if r == 255 and g == 255 and b == 255:
return r, g, b, a
else:
return 0, 0, 0, a
def rgba32_triplet_to_argb8(r, g, b, a):
"""
converts a 32-bit RGBA color by channel to an ARGB8 (1 byte containing all 4 channels)
"""
a, r, g, b = (a >> 6, r >> 6, g >> 6, b >> 6)
argb8 = (a << 6) | (r << 4) | (g << 2) | b
return argb8
# convert 32-bit color (r, g, b, a) to 32-bit RGBA word
def rgba32_triplet_to_rgba32(r, g, b, a):
return (((r & 0xFF) << 24) | ((g & 0xFF) << 16) | ((b & 0xFF) << 8) | (a & 0xFF))
# takes number of colors and outputs PNG & PBI compatible bit depths for paletted images
def num_colors_to_bitdepth(num_colors):
bitdepth = int(math.ceil(math.log(num_colors, 2)))
# only bitdepth 1,2,4 and 8 supported by PBI and PNG
if bitdepth == 0:
# caused when palette has only 1 color
bitdepth = 1
elif bitdepth == 3:
bitdepth = 4
elif bitdepth > 4:
bitdepth = 8
return bitdepth

View File

@@ -0,0 +1,485 @@
# coding=utf-8
from exceptions import *
from StringIO import StringIO
from functools import partial
from lxml import etree
import cairosvg
from cairosvg.parser import Tree
from cairosvg.surface import size, node_format, normalize, gradient_or_pattern, color
from cairosvg.surface.helpers import point, paint
import io
from pdc import PathCommand, CircleCommand, extend_bounding_box, bounding_box_around_points
from annotation import Annotation, NS_ANNOTATION, PREFIX_ANNOTATION, TAG_HIGHLIGHT
from pebble_image_routines import truncate_color_to_pebble64_palette, rgba32_triplet_to_argb8
try:
import cairocffi as cairo
# OSError means cairocffi is installed,
# but could not load a cairo dynamic library.
# pycairo may still be available with a statically-linked cairo.
except (ImportError, OSError):
import cairo # pycairo
def cairo_from_png(path):
surface = cairo.ImageSurface.create_from_png(path)
return surface, cairo.Context(surface)
class PDCContext(cairo.Context):
def line_to(self, x, y):
super(PDCContext, self).line_to(x, y)
# http://effbot.org/zone/element-lib.htm#prettyprint
def indent(elem, level=0):
i = "\n" + level*" "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
indent(elem, level+1)
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
class PDCSurface(cairosvg.surface.PNGSurface):
# noinspection PyMissingConstructor
def __init__(self, tree, output, dpi, parent_surface=None, approximate_bezier=False):
self.svg_tree = tree
self.cairo = None
self.cairosvg_tags = []
self.pdc_commands = []
self.approximate_bezier = approximate_bezier
self.stored_size = None
self.context_width, self.context_height = None, None
self.cursor_position = [0, 0]
self.cursor_d_position = [0, 0]
self.text_path_width = 0
self.tree_cache = {(tree.url, tree["id"]): tree}
if parent_surface:
self.markers = parent_surface.markers
self.gradients = parent_surface.gradients
self.patterns = parent_surface.patterns
self.masks = parent_surface.masks
self.paths = parent_surface.paths
self.filters = parent_surface.filters
else:
self.markers = {}
self.gradients = {}
self.patterns = {}
self.masks = {}
self.paths = {}
self.filters = {}
self.page_sizes = []
self._old_parent_node = self.parent_node = None
self.output = output
self.dpi = dpi
self.font_size = size(self, "12pt")
self.stroke_and_fill = True
# we only support px
for unit in ["mm", "cm", "in", "pt", "pc"]:
for attr in ["width", "height"]:
value = tree.get(attr)
if value is not None and unit in value:
tree.pop(attr)
value = None
width, height, viewbox = node_format(self, tree)
# Actual surface dimensions: may be rounded on raster surfaces types
self.cairo, self.width, self.height = self._create_surface(
width * self.device_units_per_user_units,
height * self.device_units_per_user_units)
self.page_sizes.append((self.width, self.height))
self.context = PDCContext(self.cairo)
# We must scale the context as the surface size is using physical units
self.context.scale(
self.device_units_per_user_units, self.device_units_per_user_units)
# SVG spec says "viewbox of size 0 means 'don't render'"
if viewbox is not None and viewbox[2] <= 0 and viewbox[3] <= 0:
return
# Initial, non-rounded dimensions
self.set_context_size(width, height, viewbox)
self.context.move_to(0, 0)
# register PDC namespace and add temporary fake attribute to propagate prefix
etree.register_namespace(PREFIX_ANNOTATION, NS_ANNOTATION)
ns_fake_attr = "{%s}foo" % NS_ANNOTATION
tree.node.set(ns_fake_attr, "bar")
# remove all PDC elements (annotations in case we're processing an annotated SVG)
def remove_pdc_elements(elem):
for child in elem:
if isinstance(child.tag, str) and child.tag.startswith("{%s}" % NS_ANNOTATION):
elem.remove(child)
else:
remove_pdc_elements(child)
remove_pdc_elements(tree.node)
self.draw_root(tree)
tree.node.attrib.pop(ns_fake_attr)
def size(self):
if self.stored_size is not None:
return self.stored_size
result = None
for command in self.pdc_commands:
result = extend_bounding_box(result, rect2=command.bounding_box())
if result is None:
return (0, 0)
# returned size is diagonal from (0, 0) to max_x/max_y
return (max(0, result[0] + result[2]), max(0, result[1] + result[3]))
def cairo_tag_func(self, tag):
return self.cairosvg_tags[0][tag]
def cairo_tags_push_and_wrap(self):
self.cairosvg_tags.append(cairosvg.surface.TAGS.copy())
custom_impl = {"polyline": polyline, "polygon": polygon, "line": line, "rect": rect, "circle": circle,
"path": path, "svg": svg}
for k,v in custom_impl.iteritems():
original = self.cairo_tag_func(k)
cairosvg.surface.TAGS[k] = partial(custom_impl[k], original=original)
def draw_root(self, node):
if node.get("display", "").upper() == 'NONE':
node.annotations = []
Annotation(node, 'Attribute display="none" for root element will be ignored.')
node.pop("display")
super(PDCSurface, self).draw_root(node)
def draw(self, node):
self.cairo_tags_push_and_wrap()
if not hasattr(node, "annotations"):
node.annotations = []
super(PDCSurface, self).draw(node)
cairosvg.surface.TAGS = self.cairosvg_tags.pop()
def element_tree(self):
indent(self.svg_tree.node)
# ensure that viewbox is always set
view_box = self.svg_tree.node.get("viewBox", "0 0 %d %d" % self.size())
self.svg_tree.node.set("viewBox", view_box)
return etree.ElementTree(self.svg_tree.node)
def render_annoations_on_top(self, png_path):
surface, ctx = cairo_from_png(png_path)
def iterate(elem):
if TAG_HIGHLIGHT == elem.tag:
args = [float(elem.get(k, "1")) for k in ["x", "y", "width", "height"]]
ctx.rectangle(*args)
ctx.set_source_rgb(1, 0, 0)
ctx.stroke()
for child in elem:
iterate(child)
iterate(self.svg_tree.node)
result = StringIO()
surface.write_to_png(result)
return result.getvalue()
def gcolor8_is_visible(color):
return (color & 0b11000000) != 0
def svg_color(surface, node, opacity, attribute, default=None):
paint_source, paint_color = paint(node.get(attribute, default))
if gradient_or_pattern(surface, node, paint_source):
return 0
(r, g, b, a) = color(paint_color, opacity)
color256 = truncate_color_to_pebble64_palette(int(r*255), int(g*255), int(b*255), int(a*255))
gcolor8 = rgba32_triplet_to_argb8(*color256)
return gcolor8 if gcolor8_is_visible(gcolor8) else 0
def svg(surface, node, original=None):
original(surface, node)
width, height, viewbox = node_format(surface, node)
if not "width" in node:
width = None if viewbox is None else viewbox[2]
if not "height" in node:
height = None if viewbox is None else viewbox[3]
if (width is not None) and (height is not None):
surface.stored_size = (width, height)
def line(surface, node, original=None):
x1, y1, x2, y2 = tuple(
size(surface, node.get(position), position[0])
for position in ("x1", "y1", "x2", "y2"))
points = [(x1, y1), (x2, y2)]
command = PathCommand(points, path_open=True, precise=True)
handle_command(surface, node, command)
return original(surface, node)
def polygon(surface, node, original=None):
return poly_element(surface, node, open=False, original=original)
def polyline(surface, node, original=None):
return poly_element(surface, node, open=True, original=original)
def poly_element(surface, node, open, original=None):
points_attr = normalize(node.get("points"))
points = []
while points_attr:
x, y, points_attr = point(surface, points_attr)
points.append((x, y))
command = PathCommand(points, path_open=True, precise=True)
command.open = open
handle_command(surface, node, command)
return original(surface, node)
def rect(surface, node, original=None):
x, y = size(surface, node.get("x"), "x"), size(surface, node.get("y"), "y")
width = size(surface, node.get("width"), "x")
height = size(surface, node.get("height"), "y")
rx = node.get("rx")
ry = node.get("ry")
if rx and (ry is None):
ry = rx
elif ry and (rx is None):
rx = ry
rx = size(surface, rx, "x")
ry = size(surface, ry, "y")
if not (rx == 0 and ry == 0):
annotation = Annotation(node, "Rounded rectangles are not supported.")
right = x + width - rx
bottom = y + height - ry
annotation.add_highlight(x, y, rx, ry)
annotation.add_highlight(right, y, rx, ry)
annotation.add_highlight(right, bottom, rx, ry)
annotation.add_highlight(x, bottom, rx, ry)
points = [(x, y), (x + width, y), (x + width, y + height), (x, y + height)]
command = PathCommand(points, path_open=False, precise=True)
handle_command(surface, node, command)
return original(surface, node)
def circle(surface, node, original=None):
r = size(surface, node.get("r"))
cx = size(surface, node.get("cx"), "x")
cy = size(surface, node.get("cy"), "y")
if r and cx is not None and cy is not None:
command = CircleCommand((cx, cy), r)
handle_command(surface, node, command)
return original(surface, node)
def cubicbezier_mid(p0, p1, p2, p3, min_dist, l, r):
t = (r[0] + l[0]) / 2
a = (1. - t)**3
b = 3. * t * (1. - t)**2
c = 3.0 * t**2 * (1.0 - t)
d = t**3
p = (a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0],
a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1])
def pt_dist(p1, p2):
return ((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)**0.5
if pt_dist(l[1], p) <= min_dist or pt_dist(r[1], p) <= min_dist:
return []
left = cubicbezier_mid(p0, p1, p2, p3, min_dist=min_dist, l=l, r=(t, p))
right = cubicbezier_mid(p0, p1, p2, p3, min_dist=min_dist, l=(t, p), r=r)
return left + [p] + right
def cubicbezier(p0, p1, p2, p3, min_dist):
return [p0] + cubicbezier_mid(p0, p1, p2, p3, min_dist, (0.0, p0), (1.0, p3)) + [p3]
class PathSurfaceContext(cairo.Context):
def __init__(self, target, node, approximate_bezier):
super(PathSurfaceContext, self).__init__(target)
self.points = []
self.path_open = True
self.node = node
self.approximate_bezier = approximate_bezier
self.grouped_annotations = {}
def get_grouped_description(self, description, *args, **kwargs):
if not description in self.grouped_annotations:
self.grouped_annotations[description] = self.add_annotation(description, *args, **kwargs)
return self.grouped_annotations[description]
def add_annotation(self, *args, **kwargs):
return Annotation(self.node, *args, **kwargs)
def add_current_point(self):
self.points.append(self.get_current_point())
def create_command(self):
return PathCommand(self.points, self.path_open, precise=True)
def move_to(self, x, y):
super(PathSurfaceContext, self).move_to(x, y)
self.add_current_point()
def line_to(self, x, y):
super(PathSurfaceContext, self).line_to(x, y)
self.add_current_point()
def rel_line_to(self, dx, dy):
super(PathSurfaceContext, self).rel_line_to(dx, dy)
self.add_current_point()
def curve_to(self, x1, y1, x2, y2, x3, y3):
first = self.get_current_point()
super(PathSurfaceContext, self).curve_to(x1, y1, x2, y2, x3, y3)
last = self.get_current_point()
# approximate bezier curve
points = cubicbezier(first, (x1, y1), (x2, y2), last, 5)
box = bounding_box_around_points(points)
if self.approximate_bezier:
description = "Curved command(s) were approximated."
self.points.extend(points[1:])
else:
description = "Element contains unsupported curved command(s)."
self.add_current_point()
link = "https://pebbletechnology.atlassian.net/wiki/display/DEV/Pebble+Draw+Commands#PebbleDrawCommands-issue-bezier"
self.get_grouped_description("Element contains unsupported curved command(s).", link=link).add_highlight(*box)
def rel_curve_to(self, dx1, dy1, dx2, dy2, dx3, dy3):
cur = self.get_current_point()
x1 = dx1 + cur[0]
y1 = dy1 + cur[1]
x2 = dx2 + cur[0]
y2 = dy2 + cur[1]
x3 = dx3 + cur[0]
y3 = dy3 + cur[1]
self.curve_to(x1, y1, x2, y2, x3, y3)
def arc(self, xc, yc, radius, angle1, angle2):
self.add_annotation_arc_unsupported(xc, yc, radius)
super(PathSurfaceContext, self).arc(xc, yc, radius, angle1, angle2)
def arc_negative(self, xc, yc, radius, angle1, angle2):
self.add_annotation_arc_unsupported(xc, yc, radius)
super(PathSurfaceContext, self).arc_negative(xc, yc, radius, angle1, angle2)
def close_path(self):
super(PathSurfaceContext, self).close_path()
self.path_open = False
def add_annotation_arc_unsupported(self, xc, yc, radius):
# caller uses context transforms to express arcs
points = [
(xc-radius, yc-radius), # top-left
(xc+radius, yc-radius), # top-right
(xc+radius, yc+radius), # bottom-right
(xc-radius, yc+radius), # bottom-left
]
box = bounding_box_around_points([self.user_to_device(*p) for p in points])
self.get_grouped_description("Element contains unsupported arc command(s).").add_highlight(*box)
class PathSurface:
def __init__(self, target, node, approximate_bezier):
self.context = PathSurfaceContext(target, node, approximate_bezier)
def path(surface, node, original=None):
# unfortunately, the original implementation has a side-effect on node
# but as it's rather complex, we'd rather reuse it and as it doesn't change the surface
# it's ok to call it with the fake surface we create here
collecting_surface = PathSurface(surface.cairo, node, surface.approximate_bezier)
result = original(collecting_surface, node)
command = collecting_surface.context.create_command()
if len(command.points) > 1:
handle_command(surface, node, command)
else:
Annotation(node, "Path needs at least two points.")
return result
class Transformer:
def __init__(self, cairo, node):
self.context = cairo
self.node = node
def transform_point(self, pt):
return self.context.user_to_device(pt[0], pt[1])
def transform_distance(self, dx, dy):
return self.context.user_to_device_distance(dx, dy)
def add_annotation(self, *args, **kwargs):
return Annotation(self.node, *args, **kwargs)
def handle_command(surface, node, command):
opacity = float(node.get("opacity", 1))
# Get stroke and fill opacity
stroke_opacity = float(node.get("stroke-opacity", 1))
fill_opacity = float(node.get("fill-opacity", 1))
if opacity < 1:
stroke_opacity *= opacity
fill_opacity *= opacity
default_fill = "black" if node.get("fill-rule") == "evenodd" else None
command.fill_color = svg_color(surface, node, fill_opacity, "fill", default_fill)
command.stroke_color = svg_color(surface, node, stroke_opacity, "stroke")
if gcolor8_is_visible(command.stroke_color):
command.stroke_width = int(size(surface, node.get("stroke-width")))
if command.stroke_width == 0:
command.stroke_color = 0
# transform
transformer = Transformer(surface.context, node)
command.transform(transformer)
if command.stroke_width and node.get("vector-effect") != "non-scaling-stroke":
transformed_stroke = transformer.transform_distance(command.stroke_width, 0)
transformed_stroke_width = (transformed_stroke[0]**2 + transformed_stroke[1]**2)**0.5
command.stroke_width = transformed_stroke_width
for annotation in node.annotations:
annotation.transform(transformer)
command.finalize(transformer)
# Manage display and visibility
display = node.get("display", "inline") != "none"
visible = display and (node.get("visibility", "visible") != "hidden") and \
(gcolor8_is_visible(command.fill_color) or gcolor8_is_visible(command.stroke_color))
if visible:
surface.pdc_commands.append(command)
def surface_from_svg(url=None, bytestring=None, approximate_bezier=False):
try:
tree = Tree(url=url, bytestring=bytestring)
except etree.XMLSyntaxError as e:
raise Svg2PdcFormatError(e.args[0])
output = io.BytesIO()
return PDCSurface(tree, output, 96, approximate_bezier=approximate_bezier)

View File

@@ -0,0 +1,2 @@
__version_info__ = (0, 0, 2)
__version__ = '.'.join(map(str, __version_info__))