linuxOS_AP05/debian/test/usr/lib/python3/dist-packages/Onboard/KeyCommon.py
2025-09-26 09:40:02 +08:00

1216 lines
38 KiB
Python

# -*- coding: UTF-8 -*-
# Copyright © 2007 Martin Böhme <martin.bohm@kubuntu.org>
# Copyright © 2008-2009 Chris Jones <tortoise@tortuga>
# Copyright © 2010 Francesco Fumanti <francesco.fumanti@gmx.net>
# Copyright © 2009, 2011-2017 marmuta <marmvta@gmail.com>
#
# This file is part of Onboard.
#
# Onboard 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.
#
# Onboard 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 <http://www.gnu.org/licenses/>.
"""
KeyCommon hosts the abstract classes for the various types of Keys.
UI-specific keys should be defined in KeyGtk or KeyKDE files.
"""
from __future__ import division, print_function, unicode_literals
from math import pi
import re
from Onboard.utils import Rect, LABEL_MODIFIERS, Modifiers, \
polygon_to_rounded_path
from Onboard.Layout import LayoutItem
### Logging ###
import logging
_logger = logging.getLogger("KeyCommon")
###############
### Config Singleton ###
from Onboard.Config import Config
config = Config()
########################
(
CHAR_TYPE,
KEYSYM_TYPE,
KEYCODE_TYPE,
MACRO_TYPE,
SCRIPT_TYPE,
KEYPRESS_NAME_TYPE,
BUTTON_TYPE,
LEGACY_MODIFIER_TYPE,
WORD_TYPE,
CORRECTION_TYPE,
) = tuple(range(1, 11))
(
SINGLE_STROKE_ACTION, # press on button down, release on up (default)
DELAYED_STROKE_ACTION, # press+release on button up (MENU)
DOUBLE_STROKE_ACTION, # press+release on button down and up, (CAPS, NMLK)
) = tuple(range(3))
actions = {
"single-stroke" : SINGLE_STROKE_ACTION,
"delayed-stroke" : DELAYED_STROKE_ACTION,
"double-stroke" : DOUBLE_STROKE_ACTION,
}
class StickyBehavior:
""" enum for sticky key behaviors """
(
CYCLE,
DOUBLE_CLICK,
LATCH_ONLY,
LOCK_ONLY,
LATCH_LOCK_NOCYCLE,
DOUBLE_CLICK_NOCYCLE,
LATCH_NOCYCLE,
LOCK_NOCYCLE,
PUSH_BUTTON,
) = tuple(range(9))
values = {"cycle" : CYCLE,
"dblclick" : DOUBLE_CLICK,
"latch" : LATCH_ONLY,
"lock" : LOCK_ONLY,
"latch-lock-nocycle" : LATCH_LOCK_NOCYCLE,
"dblclick-nocycle" : DOUBLE_CLICK_NOCYCLE,
"latch-nocycle" : LATCH_NOCYCLE,
"lock-nocycle" : LOCK_NOCYCLE,
"push" : PUSH_BUTTON,
}
@staticmethod
def from_string(str_value):
""" Raises KeyError """
return StickyBehavior.values[str_value]
@staticmethod
def is_valid(behavior):
return behavior in StickyBehavior.values.values()
@staticmethod
def can_latch(behavior):
"""
Can sticky key enter latched state?
Latched keys are automatically released when a
non-sticky key is pressed.
"""
return behavior in (StickyBehavior.CYCLE,
StickyBehavior.DOUBLE_CLICK,
StickyBehavior.LATCH_ONLY,
StickyBehavior.LATCH_LOCK_NOCYCLE,
StickyBehavior.DOUBLE_CLICK_NOCYCLE,
StickyBehavior.LATCH_NOCYCLE)
@staticmethod
def can_lock(behavior):
return StickyBehavior.can_lock_on_single_click(behavior) or \
StickyBehavior.can_lock_on_double_click(behavior)
@staticmethod
def can_lock_on_single_click(behavior):
"""
Can sticky key enter locked state?
Locked keys stay active until they are pressed again.
"""
return behavior in (StickyBehavior.CYCLE,
StickyBehavior.LOCK_ONLY,
StickyBehavior.LATCH_LOCK_NOCYCLE,
StickyBehavior.LOCK_NOCYCLE)
@staticmethod
def can_lock_on_double_click(behavior):
"""
Can sticky key enter locked state on double click?
Locked keys stay active until they are pressed again.
"""
return behavior == StickyBehavior.DOUBLE_CLICK or \
behavior == StickyBehavior.DOUBLE_CLICK_NOCYCLE
@staticmethod
def can_cycle(behavior):
"""
Can sticky key return to normal state?
Latched keys are still automatically released when a
non-sticky key is pressed.
"""
return behavior in (StickyBehavior.CYCLE,
StickyBehavior.DOUBLE_CLICK,
StickyBehavior.LATCH_ONLY,
StickyBehavior.LOCK_ONLY)
class LOD:
""" enum for level of detail """
(
MINIMAL, # clearly visible reduced detail, fastest
REDUCED, # slightly reduced detail
FULL, # full detail
) = tuple(range(3))
class ImageSlot:
NORMAL = 0
ACTIVE = 1
class KeyCommon(LayoutItem):
"""
library-independent key class. Specific rendering options
are stored elsewhere.
"""
# extended id for key specific theme tweaks
# e.g. theme_id=DELE.numpad (with id=DELE)
theme_id = None
# extended id for layout specific tweaks
# e.g. "hide.wordlist", for hide button in wordlist mode
svg_id = None
# optional id of a sublayout used as long-press popup
popup_id = None
# Type of action to do when key is pressed.
action = None
# Type of key stroke to send
type = None
# Data used in sending key strokes.
code = None
# Keys that stay stuck when pressed like modifiers.
sticky = False
# Behavior if sticky is enabled, see StickyBehavior.
sticky_behavior = None
# modifier bit
modifier = None
# True when key is being hovered over (not implemented yet)
prelight = False
# True when key is being pressed.
pressed = False
# True when key stays 'on'
active = False
# True when key is sticky and pressed twice.
locked = False
# True when Onboard is in scanning mode and key is highlighted
scanned = False
# True when action was triggered e.g. key-strokes were sent on press
activated = False
# Size to draw the label text in Pango units
font_size = 1
# Labels which are displayed by this key
labels = None # {modifier_mask : label, ...}
# label that is currently displayed by this key
label = ""
# mod_mask for the currently configured label
mod_mask = 0
# smaller label of a currently invisible modifier level
secondary_label = ""
# Images displayed by this key (optional)
image_filenames = None
# horizontal label alignment
label_x_align = config.DEFAULT_LABEL_X_ALIGN
# vertical label alignment
label_y_align = config.DEFAULT_LABEL_Y_ALIGN
# label margin (x, y)
label_margin = config.LABEL_MARGIN
# tooltip text
tooltip = None
# can show label popup
label_popup = True
###################
def __init__(self):
LayoutItem.__init__(self)
def configure_label(self, mod_mask):
SHIFT = Modifiers.SHIFT
labels = self.labels
if labels is None:
self.label = self.secondary_label = ""
return
# primary label
label = labels.get(mod_mask)
if label is None:
mask = mod_mask & LABEL_MODIFIERS
label = labels.get(mask)
# secondary label, usually the label of the shift state
secondary_label = None
if not label is None:
if mod_mask & SHIFT:
mask = mod_mask & ~SHIFT
else:
mask = mod_mask | SHIFT
secondary_label = labels.get(mask)
if secondary_label is None:
mask = mask & LABEL_MODIFIERS
secondary_label = labels.get(mask)
# Only keep secondary labels that show different characters
if not secondary_label is None and \
secondary_label.upper() == label.upper():
secondary_label = None
if label is None:
# legacy fallback for 0.98 behavior and virtkey until 0.61.0
if mod_mask & Modifiers.SHIFT:
if mod_mask & Modifiers.ALTGR and 129 in labels:
label = labels[129]
elif 1 in labels:
label = labels[1]
elif 2 in labels:
label = labels[2]
elif mod_mask & Modifiers.ALTGR and 128 in labels:
label = labels[128]
elif mod_mask & Modifiers.CAPS: # CAPS lock
if 2 in labels:
label = labels[2]
elif 1 in labels:
label = labels[1]
if label is None:
label = labels.get(0)
if label is None:
label = ""
self.mod_mask = mod_mask
self.label = label
self.secondary_label = secondary_label
# Don't let erroneous labels shrink their whole size group.
self.ignore_group = label.startswith("0x")
def draw_label(self, context = None):
raise NotImplementedError()
def set_labels(self, labels):
self.labels = labels
self.configure_label(0)
def get_label(self):
return self.label
def get_secondary_label(self):
return self.secondary_label
def is_active(self):
return not self.type is None
def get_id(self):
return ""
def get_svg_id(self):
return ""
def set_id(self, id, theme_id = None, svg_id = None):
self.theme_id, self.id = self.parse_id(id)
if theme_id:
self.theme_id = theme_id
self.svg_id = self.id if not svg_id else svg_id
@staticmethod
def parse_id(value):
"""
The theme id has the form <id>.<arbitrary identifier>, where
the identifier should be a description of the location of
the key relative to its surroundings, e.g. 'DELE.next-to-backspace'.
Don't use layout names or layer ids for the theme id, they lose
their meaning when layouts are copied or renamed by users.
"""
theme_id = value
id = value.split(".")[0]
return theme_id, id
@staticmethod
def split_theme_id(theme_id):
"""
Simple split in prefix (id) before the dot and suffix after the dot.
"""
components = theme_id.split(".")
if len(components) == 1:
return components[0], ""
return components[0], components[1]
@staticmethod
def build_theme_id(prefix, postfix):
if postfix:
return prefix + "." + postfix
return prefix
def get_similar_theme_id(self, prefix = None):
if prefix is None:
prefix = self.id
theme_id = prefix
comps = self.theme_id.split(".")[1:]
if comps:
theme_id += "." + comps[0]
return theme_id
def is_layer_button(self):
return self.id.startswith("layer")
def is_prediction_key(self):
return self.id.startswith("prediction")
def is_correction_key(self):
return self.id.startswith("correction") or \
self.id in ["expand-corrections"]
def is_word_suggestion(self):
return self.is_prediction_key() or self.is_correction_key()
def is_modifier(self):
"""
Modifiers are all latchable/lockable non-button keys:
"LWIN", "RTSH", "LFSH", "RALT", "LALT",
"RCTL", "LCTL", "CAPS", "NMLK"
"""
return bool(self.modifier)
def is_click_type_key(self):
return self.id in ["singleclick",
"secondaryclick",
"middleclick",
"doubleclick",
"dragclick"]
def is_button(self):
return self.type == BUTTON_TYPE
def is_pressed_only(self):
return self.pressed and not (self.active or \
self.locked or \
self.scanned)
def is_text_changing(self):
if not self.is_modifier() and \
self.type in [KEYCODE_TYPE,
KEYSYM_TYPE,
CHAR_TYPE,
KEYPRESS_NAME_TYPE,
MACRO_TYPE,
WORD_TYPE,
CORRECTION_TYPE]:
id = self.id
if not (id.startswith("F") and id[1:].isdigit()) and \
not id in set(["LEFT", "RGHT", "UP", "DOWN",
"HOME", "END", "PGUP", "PGDN",
"INS", "ESC", "MENU",
"Prnt", "Pause", "Scroll"]):
return True
return False
def is_return(self):
id = self.id
return (id == "RTRN" or
id == "KPEN")
def is_separator(self):
id = self.id
return (id == "SPCE" or
id == "TAB")
def is_separator_cancelling(self):
""" Should this key cancel pending word separators? """
return (self.is_correction_key() or
self.is_return() or
self.id in set(["SPCE", "TAB",
# Don't cancel for Backspace. We want to have
# it appear to delete the pending separator.
# This way it inserts a space, then immediately
# deletes it.
# "BKSP",
"DELE",
"LEFT", "RGHT", "UP", "DOWN",
"HOME", "END", "PGUP", "PGDN",
"INS", "ESC", "MENU",
"Prnt", "Pause", "Scroll"]))
def get_layer_index(self):
assert(self.is_layer_button())
return int(self.id[5:])
def get_popup_layout(self):
if self.popup_id:
return self.find_sublayout(self.popup_id)
return None
def can_show_label_popup(self):
return not self.is_modifier() and \
not self.is_layer_button() and \
not self.type is None and \
bool(self.label_popup)
class RectKeyCommon(KeyCommon):
""" An abstract class for rectangular keyboard buttons """
# optional path data for keys with arbitrary shapes
geometry = None
# size of rounded corners at 100% round_rect_radius
chamfer_size = None
# Optional key_style to override the default theme's style.
style = None
# Toggles for what gets drawn.
show_face = True
show_border = True
show_label = True
show_image = True
# Allow to display active state, i.e. either latched or locked state.
# Depending on sticky_behavior the button will still become logically
# active, it just isn't shown. Used for layer0 buttons, mainly. They don't
# need to stick out, it's usually obvious when the first layer is active.
show_active = True
def __init__(self, id, border_rect):
KeyCommon.__init__(self)
self.id = id
self.colors = {}
self.context.log_rect = border_rect \
if not border_rect is None else Rect()
def get_id(self):
return self.id
def get_svg_id(self):
return self.svg_id
def get_state(self):
state = {}
state["prelight"] = self.prelight
state["pressed"] = self.pressed
state["active"] = self.active
state["locked"] = self.locked
state["scanned"] = self.scanned
state["sensitive"] = self.sensitive
return state
def draw(self, context = None):
pass
def align_label(self, label_size, key_size, ltr = True):
""" returns x- and yoffset of the aligned label """
label_x_align = self.label_x_align
label_y_align = self.label_y_align
if not ltr: # right to left script?
label_x_align = 1.0 - label_x_align
xoffset = label_x_align * (key_size[0] - label_size[0])
yoffset = label_y_align * (key_size[1] - label_size[1])
return xoffset, yoffset
def align_secondary_label(self, label_size, key_size, ltr = True):
""" returns x- and yoffset of the aligned label """
label_x_align = 0.97
label_y_align = 0.0
if not ltr: # right to left script?
label_x_align = 1.0 - label_x_align
xoffset = label_x_align * (key_size[0] - label_size[0])
yoffset = label_y_align * (key_size[1] - label_size[1])
return xoffset, yoffset
def align_popup_indicator(self, label_size, key_size, ltr = True):
""" returns x- and yoffset of the aligned label """
label_x_align = 1.0
label_y_align = self.label_y_align
if not ltr: # right to left script?
label_x_align = 1.0 - label_x_align
xoffset = label_x_align * (key_size[0] - label_size[0])
yoffset = label_y_align * (key_size[1] - label_size[1])
return xoffset, yoffset
def get_style(self):
if not self.style is None:
return self.style
return config.theme_settings.key_style
def get_stroke_width(self):
return config.theme_settings.key_stroke_width / 100.0
def get_stroke_gradient(self):
return config.theme_settings.key_stroke_gradient / 100.0
def get_light_direction(self):
return config.theme_settings.key_gradient_direction * pi / 180.0
def get_fill_color(self):
return self._get_color("fill")
def get_stroke_color(self):
return self._get_color("stroke")
def get_label_color(self):
return self._get_color("label")
def get_secondary_label_color(self):
return self._get_color("secondary-label")
def get_dwell_progress_color(self):
return self._get_color("dwell-progress")
def get_dwell_progress_canvas_rect(self):
rect = self.get_label_rect().inflate(0.5)
return self.context.log_to_canvas_rect(rect)
def _get_color(self, element):
color_key = (element, self.prelight, self.pressed,
self.active, self.locked,
self.sensitive, self.scanned)
rgba = self.colors.get(color_key)
if not rgba:
if self.color_scheme:
rgba = self.color_scheme.get_key_rgba(self, element)
elif element == "label":
rgba = [0.0, 0.0, 0.0, 1.0]
else:
rgba = [1.0, 1.0, 1.0, 1.0]
self.colors[color_key] = rgba
return rgba
def get_fullsize_rect(self):
""" Get bounding box of the key at 100% size in logical coordinates """
return LayoutItem.get_rect(self)
def get_canvas_fullsize_rect(self):
""" Get bounding box of the key at 100% size in canvas coordinates """
return self.context.log_to_canvas_rect(self.get_fullsize_rect())
def get_unpressed_rect(self):
"""
Get bounding box in logical coordinates.
Just the relatively static unpressed rect withough fake key action.
"""
rect = self.get_fullsize_rect()
return self._apply_key_size(rect)
def get_rect(self):
""" Get bounding box in logical coordinates """
return self.get_sized_rect()
def get_sized_rect(self, horizontal = None):
rect = self.get_fullsize_rect()
# fake physical key action
if self.pressed:
dx, dy, dw, dh = self.get_pressed_deltas()
rect.x += dx
rect.y += dy
rect.w += dw
rect.h += dh
return self._apply_key_size(rect, horizontal)
@staticmethod
def _apply_key_size(rect, horizontal = None):
""" shrink keys to key_size """
scale = (1.0 - config.theme_settings.key_size / 100.0) * 0.5
bx = rect.w * scale
by = rect.h * scale
if horizontal is None:
horizontal = rect.h < rect.w
if horizontal:
# keys with aspect > 1.0, e.g. space, shift
bx = by
else:
# keys with aspect < 1.0, e.g. click, move, number block + and enter
by = bx
return rect.deflate(bx, by)
def get_pressed_deltas(self):
"""
dx, dy, dw, dh for fake physical key action of pressed keys.
Logical coordinate system.
"""
key_style = self.get_style()
if key_style == "gradient":
k = 0.2
elif key_style == "dish":
k = 0.45
else:
k = 0.0
return k, 2*k, 0.0, 0.0
def get_label_rect(self, rect = None):
""" Label area in logical coordinates """
if rect is None:
rect = self.get_rect()
style = self.get_style()
if style == "dish":
stroke_width = self.get_stroke_width()
border_x, border_y = config.DISH_KEY_BORDER
border_x *= stroke_width
border_y *= stroke_width
rect = rect.deflate(border_x, border_y)
rect.y -= config.DISH_KEY_Y_OFFSET * stroke_width
return rect
else:
return rect.deflate(*self.label_margin)
def get_canvas_label_rect(self):
log_rect = self.get_label_rect()
return self.context.log_to_canvas_rect(log_rect)
def get_border_path(self):
""" Original path including border in logical coordinates. """
return self.geometry.get_full_size_path()
def get_path(self):
"""
Path of the key geometry in logical coordinates.
Key size and fake press movement are applied.
"""
offset_x, offset_y, size_x, size_y = self.get_key_offset_size()
return self.geometry.get_transformed_path(offset_x, offset_y,
size_x, size_y)
def get_canvas_border_path(self):
path = self.get_border_path()
return self.context.log_to_canvas_path(path)
def get_canvas_path(self):
path = self.get_path()
return self.context.log_to_canvas_path(path)
def get_hit_path(self):
return self.get_canvas_border_path()
def get_chamfer_size(self, rect = None):
""" Max size of the rounded corner areas in logical coordinates. """
if not self.chamfer_size is None:
return self.chamfer_size
if not rect:
if self.geometry:
rect = self.get_border_path().get_bounds()
else:
rect = self.get_rect()
return min(rect.w, rect.h) * 0.5
def get_key_offset_size(self, geometry = None):
size_x = size_y = config.theme_settings.key_size / 100.0
offset_x = offset_y = 0.0
if self.pressed:
offset_x, offset_y, dw, dh = self.get_pressed_deltas()
if dw != 0.0 or dh != 0.0:
if geometry is None:
geometry = self.geometry
dw, dh = geometry.scale_log_to_size((dw, dh))
size_x += dw * 0.5
size_y += dh * 0.5
return offset_x, offset_y, size_x, size_y
def get_canvas_polygons(self, geometry,
offset_x, offset_y, size_x, size_y,
radius_pct, chamfer_size):
path = geometry.get_transformed_path(offset_x, offset_y, size_x, size_y)
canvas_path = self.context.log_to_canvas_path(path)
polygons = list(canvas_path.iter_polygons())
polygon_paths = \
[polygon_to_rounded_path(p, radius_pct, chamfer_size) \
for p in polygons]
return polygons, polygon_paths
class InputlineKeyCommon(RectKeyCommon):
""" An abstract class for InputLine keyboard buttons """
line = ""
word_infos = None
cursor = 0
def __init__(self, name, border_rect):
RectKeyCommon.__init__(self, name, border_rect)
def get_label(self):
return ""
class KeyGeometry:
"""
Full description of a key's shape.
This class generates path variants for a given key_size by path
interpolation. This allows for key_size dependent shape changes,
controlled solely by a SVG layout file. See 'Return' key in
'Full Keyboard' layout for an example.
"""
path0 = None # KeyPath at 100% size
path1 = None # KepPath at 50% size, optional
@staticmethod
def from_paths(paths):
assert(len(paths) >= 1)
path0 = paths[0]
path1 = None
if len(paths) >= 2:
path1 = paths[1]
# Equal number of path segments?
if len(path0.segments) != len(path1.segments):
raise ValueError(
"paths to interpolate differ in number of segments "
"({} vs. {})" \
.format(len(path0.segments), len(path1.segments)))
# Same operations in all path segments?
for i in range(len(path0.segments)):
op0, coords0 = path0.segments[i]
op1, coords1 = path1.segments[i]
if op0 != op1:
raise ValueError(
"paths to interpolate have different operations "
"at segment {} (op. {} vs. op. {})" \
.format(i, op0, op1))
geometry = KeyGeometry()
geometry.path0 = path0
geometry.path1 = path1
return geometry
@staticmethod
def from_rect(rect):
geometry = KeyGeometry()
geometry.path0 = KeyPath.from_rect(rect)
return geometry
def get_transformed_path(self, offset_x = 0.0, offset_y = 0.0,
size_x = 1.0, size_y = 1.0):
"""
Everything in the logical coordinate system.
size: 1.0 => path0, 0.5 => path1
"""
path0 = self.path0
path1 = self.path1
if path1:
pos_x = (1 - size_x) * 2.0
pos_y = (1 - size_y) * 2.0
return path0.linint(path1, pos_x, pos_y, offset_x, offset_y)
else:
r0 = self.get_full_size_bounds()
r1 = self.get_half_size_bounds()
rect = r1.inflate((size_x - 0.5) * (r0.w - r1.w),
(size_y - 0.5) * (r0.h - r1.h))
rect.x += offset_x
rect.y += offset_y
return path0.fit_in_rect(rect)
def get_full_size_path(self):
return self.path0
def get_full_size_bounds(self):
"""
Bounding box at size 1.0.
"""
return self.path0.get_bounds()
def get_half_size_bounds(self):
"""
Bounding box at size 0.5.
"""
path1 = self.path1
if path1:
rect = path1.get_bounds()
else:
rect = self.path0.get_bounds()
if rect.h < rect.w:
dx = dy = rect.h * 0.25
else:
dy = dx = rect.w * 0.25
rect = rect.deflate(dx, dy)
return rect
def scale_log_to_size(self, v):
""" Scale from logical distances to key size. """
r0 = self.get_full_size_bounds()
r1 = self.get_half_size_bounds()
log_h = (r0.h - r1.h) * 2.0
log_w = (r0.w - r1.w) * 2.0
return (v[0] / log_h,
v[1] / log_w)
def scale_size_to_log(self, v):
""" Scale from logical distances to key size. """
r0 = self.get_full_size_bounds()
r1 = self.get_half_size_bounds()
log_h = (r0.h - r1.h) * 2.0
log_w = (r0.w - r1.w) * 2.0
return (v[0] * log_h,
v[1] * log_w)
class KeyPath:
"""
Cairo-friendly path description for non-rectangular keys.
Can handle straight line-loops/polygons, but not arcs and splines.
"""
(
MOVE_TO,
LINE_TO,
CLOSE_PATH,
) = range(3)
_last_abs_pos = (0.0, 0.0)
_bounds = None # cached bounding box
def __init__(self):
self.segments = [] # normalized list of path segments (all absolute)
@staticmethod
def from_svg_path(path_str):
path = KeyPath()
path.append_svg_path(path_str)
return path
@staticmethod
def from_rect(rect):
x0 = rect.x
y0 = rect.y
x1 = rect.right()
y1 = rect.bottom()
path = KeyPath()
path.segments = [[KeyPath.MOVE_TO, [x0, y0]],
[KeyPath.LINE_TO, [x1, y0, x1, y1, x0, y1]],
[KeyPath.CLOSE_PATH, []]]
path._bounds = rect.copy()
return path
_svg_path_pattern = re.compile("([+-]?[0-9.]+)")
def copy(self):
result = KeyPath()
for op, coords in self.segments:
result.segments.append([op, coords[:]])
return result
def append_svg_path(self, path_str):
"""
Append a SVG path data string to the path.
Doctests:
# absolute move_to command
>>> p = KeyPath.from_svg_path("M 100 200 120 -220")
>>> print(p.segments)
[[0, [100.0, 200.0]], [1, [120.0, -220.0]]]
# relative move_to command
>>> p = KeyPath.from_svg_path("m 100 200 10 -10")
>>> print(p.segments)
[[0, [100.0, 200.0]], [1, [110.0, 190.0]]]
# relative move_to and close_path segments
>>> p = KeyPath.from_svg_path("m 100 200 10 -10 z")
>>> print(p.segments)
[[0, [100.0, 200.0]], [1, [110.0, 190.0]], [2, []]]
# spaces and commas and are optional where possible
>>> p = KeyPath.from_svg_path("m100,200 10-10z")
>>> print(p.segments)
[[0, [100.0, 200.0]], [1, [110.0, 190.0]], [2, []]]
"""
cmd_str = ""
coords = []
tokens = self._tokenize_svg_path(path_str)
for token in tokens:
try:
val = float(token) # raises value error
coords.append(val)
except ValueError:
if token.isalpha():
if cmd_str:
self.append_command(cmd_str, coords)
cmd_str = token
coords = []
elif token == ",":
pass
else:
raise ValueError(
"unexpected token '{}' in svg path data" \
.format(token))
if cmd_str:
self.append_command(cmd_str, coords)
def append_command(self, cmd_str, coords):
"""
Append a single command and it's coordinate data to the path.
Doctests:
# first lowercase move_to position is absolute
>>> p = KeyPath()
>>> p.append_command("m", [100, 200])
>>> print(p.segments)
[[0, [100, 200]]]
# move_to segments become line_to segments after the first position
>>> p = KeyPath()
>>> p.append_command("M", [100, 200, 110, 190])
>>> print(p.segments)
[[0, [100, 200]], [1, [110, 190]]]
# further lowercase move_to positions are relative, must become absolute
>>> p = KeyPath()
>>> p.append_command("m", [100, 200, 10, -10, 10, -10])
>>> print(p.segments)
[[0, [100, 200]], [1, [110, 190, 120, 180]]]
# further lowercase segments must still be become absolute
>>> p = KeyPath()
>>> p.append_command("m", [100, 200, 10, -10, 10, -10])
>>> p.append_command("l", [1, -1, 1, -1])
>>> print(p.segments)
[[0, [100, 200]], [1, [110, 190, 120, 180]], [1, [121, 179, 122, 178]]]
"""
# Convert lowercase segments from relative to absolute coordinates.
if cmd_str in ("m", "l"):
# Don't convert the very first coordinate, it is already absolute.
if self.segments:
start = 0
x, y = self._last_abs_pos
else:
start = 2
x, y = coords[0], coords[1]
for i in range(start, len(coords), 2):
x += coords[i]
y += coords[i+1]
coords[i] = x
coords[i+1] = y
cmd = cmd_str.lower()
if cmd == "m":
self.segments.append([self.MOVE_TO, coords[:2]])
if len(coords) > 2:
self.segments.append([self.LINE_TO, coords[2:]])
elif cmd == "l":
self.segments.append([self.LINE_TO, coords])
elif cmd == "z":
self.segments.append([self.CLOSE_PATH, []])
# remember last absolute position
if len(coords) >= 2:
self._last_abs_pos = coords[-2:]
@staticmethod
def _tokenize_svg_path(path_str):
"""
Split SVG path date into command and coordinate tokens.
Doctests:
>>> KeyPath._tokenize_svg_path("m 10,20")
['m', '10', ',', '20']
>>> KeyPath._tokenize_svg_path(" m 10 , \\n 20 ")
['m', '10', ',', '20']
>>> KeyPath._tokenize_svg_path("m 10,20 30,40 z")
['m', '10', ',', '20', '30', ',', '40', 'z']
>>> KeyPath._tokenize_svg_path("m10,20 30,40z")
['m', '10', ',', '20', '30', ',', '40', 'z']
>>> KeyPath._tokenize_svg_path("M100.32 100.09 100. -100.")
['M', '100.32', '100.09', '100.', '-100.']
>>> KeyPath._tokenize_svg_path("m123+23 20,-14L200,200")
['m', '123', '+23', '20', ',', '-14', 'L', '200', ',', '200']
>>> KeyPath._tokenize_svg_path("m123+23 20,-14L200,200")
['m', '123', '+23', '20', ',', '-14', 'L', '200', ',', '200']
"""
tokens = [token.strip() \
for token in KeyPath._svg_path_pattern.split(path_str)]
return [token for token in tokens if token]
def get_bounds(self):
bounds = self._bounds
if bounds is None:
bounds = self._calc_bounds()
self._bounds = bounds
return bounds
def _calc_bounds(self):
"""
Compute the bounding box of the path.
Doctests:
# Simple move_to path, something inkscape would create.
>>> p = KeyPath.from_svg_path("m 100,200 10,-10 z")
>>> print(p.get_bounds())
Rect(x=100.0 y=190.0 w=10.0 h=10.0)
"""
try:
xmin = xmax = self.segments[0][1][0]
ymin = ymax = self.segments[0][1][1]
except IndexError:
return Rect()
for command in self.segments:
coords = command[1]
for i in range(0, len(coords), 2):
x = coords[i]
y = coords[i+1]
if xmin > x:
xmin = x
if xmax < x:
xmax = x
if ymin > y:
ymin = y
if ymax < y:
ymax = y
return Rect(xmin, ymin, xmax - xmin, ymax - ymin)
def inflate(self, dx, dy = None):
"""
Returns a new path which is larger by dx and dy on all sides.
"""
rect = self.get_bounds().inflate(dx, dy)
return self.fit_in_rect(rect)
def fit_in_rect(self, rect):
"""
Scales and translates the path so that rect
becomes its new bounding box.
"""
result = self.copy()
bounds = self.get_bounds()
scalex = rect.w / bounds.w
scaley = rect.h / bounds.h
dorgx, dorgy = bounds.get_center()
dx = rect.x - (dorgx + (bounds.x - dorgx) * scalex)
dy = rect.y - (dorgy + (bounds.y - dorgy) * scaley)
for op, coords in result.segments:
for i in range(0, len(coords), 2):
coords[i] = dx + dorgx + (coords[i] - dorgx) * scalex
coords[i+1] = dy + dorgy + (coords[i+1] - dorgy) * scaley
return result
def linint(self, path1, pos_x = 1.0, pos_y = 1.0,
offset_x = 0.0, offset_y = 0.0):
"""
Interpolate between self and path1.
Paths must have the same structure (length and operations).
pos: 0.0 = self, 1.0 = path1.
"""
result = self.copy()
segments = result.segments
segments1 = path1.segments
for i in range(len(segments)):
op, coords = segments[i]
op1, coords1 = segments1[i]
for j in range(0, len(coords), 2):
x = coords[j]
y = coords[j+1]
x1 = coords1[j]
y1 = coords1[j+1]
dx = x1 - x
dy = y1 - y
coords[j] = x + pos_x * dx + offset_x
coords[j+1] = y + pos_y * dy + offset_y
return result
def iter_polygons(self):
"""
Loop through all independent polygons in the path.
Can't handle splines and arcs, everything has to
be polygons from here.
"""
polygon = []
for op, coords in self.segments:
if op == self.LINE_TO:
polygon.extend(coords)
elif op == self.MOVE_TO:
polygon = []
polygon.extend(coords)
elif op == self.CLOSE_PATH:
yield polygon
def is_point_within(self, point):
for polygon in self.iter_polygons():
if self.is_point_in_polygon(polygon, point[0], point[1]):
return True
@staticmethod
def is_point_in_polygon(vertices, x, y):
c = False
n = len(vertices)
try:
x0 = vertices[n - 2]
y0 = vertices[n - 1]
except IndexError:
return False
for i in range(0, n, 2):
x1 = vertices[i]
y1 = vertices[i+1]
if (y1 <= y and y < y0 or y0 <= y and y < y1) and \
(x < (x0 - x1) * (y - y1) / (y0 - y1) + x1):
c = not c
x0 = x1
y0 = y1
return c