strokeoutline 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ """Deterministic variable-width stroke outlining for planar centerlines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from planarcurve import CubicBezier, CubicPath, Point2D, QuadraticBezier
8
+
9
+ from .core import (
10
+ DEFAULT_OUTLINE_CONFIG,
11
+ CapStyle,
12
+ Contour,
13
+ DegeneratePathError,
14
+ InvalidWidthError,
15
+ OutlineConfig,
16
+ OutlineError,
17
+ OutlineResult,
18
+ SubdivisionLimitError,
19
+ WidthProfile,
20
+ combine_outlines,
21
+ expand_tap,
22
+ outline,
23
+ outline_disc,
24
+ signed_area,
25
+ winding,
26
+ )
27
+ from .model import (
28
+ Line,
29
+ Path2D,
30
+ Segment,
31
+ Subpath,
32
+ path_from_cubic_path,
33
+ path_from_polyline,
34
+ )
35
+
36
+ try:
37
+ __version__ = version("strokeoutline")
38
+ except PackageNotFoundError:
39
+ __version__ = "0.0.0+unknown"
40
+
41
+ __all__ = [
42
+ "DEFAULT_OUTLINE_CONFIG",
43
+ "CapStyle",
44
+ "Contour",
45
+ "CubicBezier",
46
+ "CubicPath",
47
+ "DegeneratePathError",
48
+ "InvalidWidthError",
49
+ "Line",
50
+ "OutlineConfig",
51
+ "OutlineError",
52
+ "OutlineResult",
53
+ "Path2D",
54
+ "Point2D",
55
+ "QuadraticBezier",
56
+ "Segment",
57
+ "Subpath",
58
+ "SubdivisionLimitError",
59
+ "WidthProfile",
60
+ "__version__",
61
+ "combine_outlines",
62
+ "expand_tap",
63
+ "outline",
64
+ "outline_disc",
65
+ "path_from_cubic_path",
66
+ "path_from_polyline",
67
+ "signed_area",
68
+ "winding",
69
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = 'g72d8e8d28'
strokeoutline/core.py ADDED
@@ -0,0 +1,563 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable, Sequence
4
+ from dataclasses import dataclass
5
+ from math import acos, atan2, ceil, cos, degrees, hypot, isfinite, pi, sin
6
+ from typing import Literal, TypeAlias
7
+
8
+ from planarcurve import CubicPath, Point2D, QuadraticBezier
9
+
10
+ from .model import Line, Path2D, Segment, Subpath, path_from_cubic_path
11
+
12
+ WidthProfile: TypeAlias = float | Sequence[tuple[float, float]] | Callable[[float], float]
13
+ Contour: TypeAlias = tuple[Point2D, ...]
14
+ CapStyle: TypeAlias = Literal["round", "square"]
15
+
16
+
17
+ class OutlineError(ValueError):
18
+ """Base class for geometric outlining failures."""
19
+
20
+
21
+ class InvalidWidthError(OutlineError):
22
+ """Raised when a width profile is malformed or non-positive."""
23
+
24
+
25
+ class DegeneratePathError(OutlineError):
26
+ """Raised when a centerline contains degenerate geometry."""
27
+
28
+
29
+ class SubdivisionLimitError(OutlineError):
30
+ """Raised when adaptive sampling cannot satisfy configured tolerances."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class OutlineConfig:
35
+ """Controls adaptive sampling and cap behavior for open paths.
36
+
37
+ Parameters
38
+ ----------
39
+ max_centerline_error : float
40
+ Maximum centerline deviation from a straight chord, in input coordinate
41
+ units. Smaller values generally produce more samples.
42
+ max_width_error : float
43
+ Maximum width-profile deviation from linear interpolation, in width
44
+ units. Smaller values sample changing widths more closely.
45
+ max_normal_angle_degrees : float
46
+ Maximum tangent direction change over a sampling interval, in degrees.
47
+ The value must be positive and less than 180.
48
+ cap : CapStyle
49
+ Cap style for open paths: ``"round"`` or ``"square"``. Ignored for
50
+ closed centerlines.
51
+ max_subdivision_depth : int
52
+ Maximum recursive subdivision depth. If sampling cannot meet the
53
+ tolerances by this depth, outlining raises ``SubdivisionLimitError``.
54
+
55
+ Notes
56
+ -----
57
+ Decreasing tolerances generally increases contour vertices and work. Keep
58
+ the defaults unless the application has a reason to tune them.
59
+ """
60
+
61
+ max_centerline_error: float = 0.25
62
+ max_width_error: float = 0.25
63
+ max_normal_angle_degrees: float = 3.0
64
+ cap: CapStyle = "round"
65
+ max_subdivision_depth: int = 18
66
+
67
+ def __post_init__(self) -> None:
68
+ tolerances = (
69
+ self.max_centerline_error,
70
+ self.max_width_error,
71
+ self.max_normal_angle_degrees,
72
+ )
73
+ if any(
74
+ isinstance(value, bool) or not isfinite(value) or value <= 0 for value in tolerances
75
+ ):
76
+ raise ValueError("outline tolerances must be positive and finite")
77
+ if self.max_normal_angle_degrees >= 180:
78
+ raise ValueError("normal angle tolerance must be less than 180 degrees")
79
+ if self.cap not in {"round", "square"}:
80
+ raise ValueError("cap must be 'round' or 'square'")
81
+ if (
82
+ isinstance(self.max_subdivision_depth, bool)
83
+ or not isinstance(self.max_subdivision_depth, int)
84
+ or self.max_subdivision_depth < 1
85
+ ):
86
+ raise ValueError("maximum subdivision depth must be a positive integer")
87
+
88
+
89
+ DEFAULT_OUTLINE_CONFIG = OutlineConfig()
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class OutlineResult:
94
+ """Result of a centerline expansion.
95
+
96
+ Parameters
97
+ ----------
98
+ contours : tuple[Contour, ...]
99
+ Output contours. Each non-empty contour is closed by repeating its
100
+ first point as its final point. The tuple may be empty.
101
+ bounds : tuple[float, float, float, float] or None
102
+ Aggregate ``(min_x, min_y, max_x, max_y)`` bounds, or ``None`` when
103
+ there are no contours, for example in ``combine_outlines(())``.
104
+ """
105
+
106
+ contours: tuple[Contour, ...]
107
+ bounds: tuple[float, float, float, float] | None
108
+
109
+
110
+ def _lerp(a: Point2D, b: Point2D, t: float) -> Point2D:
111
+ return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
112
+
113
+
114
+ def _point_on(segment: Segment, t: float) -> Point2D:
115
+ if isinstance(segment, Line):
116
+ return _lerp(segment.p0, segment.p1, t)
117
+ if isinstance(segment, QuadraticBezier):
118
+ return _lerp(_lerp(segment.p0, segment.p1, t), _lerp(segment.p1, segment.p2, t), t)
119
+ a = _lerp(segment.p0, segment.p1, t)
120
+ b = _lerp(segment.p1, segment.p2, t)
121
+ c = _lerp(segment.p2, segment.p3, t)
122
+ return _lerp(_lerp(a, b, t), _lerp(b, c, t), t)
123
+
124
+
125
+ def _tangent(segment: Segment, t: float) -> Point2D:
126
+ if isinstance(segment, Line):
127
+ return (segment.p1[0] - segment.p0[0], segment.p1[1] - segment.p0[1])
128
+ if isinstance(segment, QuadraticBezier):
129
+ return (
130
+ 2 * ((1 - t) * (segment.p1[0] - segment.p0[0]) + t * (segment.p2[0] - segment.p1[0])),
131
+ 2 * ((1 - t) * (segment.p1[1] - segment.p0[1]) + t * (segment.p2[1] - segment.p1[1])),
132
+ )
133
+ u = 1 - t
134
+ return (
135
+ 3
136
+ * (
137
+ u * u * (segment.p1[0] - segment.p0[0])
138
+ + 2 * u * t * (segment.p2[0] - segment.p1[0])
139
+ + t * t * (segment.p3[0] - segment.p2[0])
140
+ ),
141
+ 3
142
+ * (
143
+ u * u * (segment.p1[1] - segment.p0[1])
144
+ + 2 * u * t * (segment.p2[1] - segment.p1[1])
145
+ + t * t * (segment.p3[1] - segment.p2[1])
146
+ ),
147
+ )
148
+
149
+
150
+ def _width_function(profile: WidthProfile) -> Callable[[float], float]:
151
+ if callable(profile):
152
+ raw = profile
153
+ elif isinstance(profile, (int, float)) and not isinstance(profile, bool):
154
+ constant = float(profile)
155
+
156
+ def raw(_u: float) -> float:
157
+ return constant
158
+
159
+ else:
160
+ try:
161
+ knots = tuple((float(position), float(width)) for position, width in profile)
162
+ except (TypeError, ValueError, OverflowError) as exc:
163
+ raise InvalidWidthError(
164
+ "width must be a positive number, knot sequence, or callable"
165
+ ) from exc
166
+ if any(
167
+ not isfinite(position) or not isfinite(width) or width <= 0 for position, width in knots
168
+ ):
169
+ raise InvalidWidthError("width profile knots must be finite with positive widths")
170
+ if len(knots) < 2:
171
+ raise InvalidWidthError("width profile requires at least two knots")
172
+ if knots[0][0] != 0 or knots[-1][0] != 1:
173
+ raise InvalidWidthError("width profile must span parameter values 0 through 1")
174
+ if any(b[0] <= a[0] for a, b in zip(knots, knots[1:], strict=False)):
175
+ raise InvalidWidthError("width profile parameters must increase")
176
+
177
+ def raw(u: float) -> float:
178
+ for (u0, w0), (u1, w1) in zip(knots, knots[1:], strict=False):
179
+ if u <= u1:
180
+ return w0 + (w1 - w0) * (u - u0) / (u1 - u0)
181
+ return knots[-1][1]
182
+
183
+ def width_at(u: float) -> float:
184
+ raw_value = raw(float(u))
185
+ if isinstance(raw_value, bool):
186
+ raise InvalidWidthError("outline widths must be finite and positive")
187
+ try:
188
+ value = float(raw_value)
189
+ except (TypeError, ValueError, OverflowError) as exc:
190
+ raise InvalidWidthError("width callable must return numeric values") from exc
191
+ if not isfinite(value) or value <= 0:
192
+ raise InvalidWidthError("outline widths must be finite and positive")
193
+ return value
194
+
195
+ return width_at
196
+
197
+
198
+ def _unit(vector: Point2D) -> Point2D:
199
+ length = hypot(*vector)
200
+ if length <= 1e-12:
201
+ raise DegeneratePathError("outline contains a zero-length tangent")
202
+ return (vector[0] / length, vector[1] / length)
203
+
204
+
205
+ def _shift(point: Point2D, direction: Point2D, distance: float) -> Point2D:
206
+ return (point[0] + direction[0] * distance, point[1] + direction[1] * distance)
207
+
208
+
209
+ def _distance_to_chord(point: Point2D, start: Point2D, end: Point2D) -> float:
210
+ dx, dy = end[0] - start[0], end[1] - start[1]
211
+ length2 = dx * dx + dy * dy
212
+ if length2 <= 1e-24:
213
+ return hypot(point[0] - start[0], point[1] - start[1])
214
+ u = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length2))
215
+ projection = (start[0] + u * dx, start[1] + u * dy)
216
+ return hypot(point[0] - projection[0], point[1] - projection[1])
217
+
218
+
219
+ def _angle_between(left: Point2D, right: Point2D) -> float:
220
+ a, b = _unit(left), _unit(right)
221
+ return degrees(acos(max(-1.0, min(1.0, a[0] * b[0] + a[1] * b[1]))))
222
+
223
+
224
+ def _flatten_segment(
225
+ segment: Segment,
226
+ segment_index: int,
227
+ segment_count: int,
228
+ width_at: Callable[[float], float],
229
+ config: OutlineConfig,
230
+ ) -> list[tuple[float, Point2D]]:
231
+ def subdivide(t0: float, t1: float, depth: int) -> list[tuple[float, Point2D]]:
232
+ p0, p1 = _point_on(segment, t0), _point_on(segment, t1)
233
+ span = t1 - t0
234
+ probes = [_point_on(segment, t0 + span * fraction) for fraction in (0.25, 0.5, 0.75)]
235
+ chord_error = max(_distance_to_chord(point, p0, p1) for point in probes)
236
+ tangent0 = _tangent(segment, t0)
237
+ tangent1 = _tangent(segment, t1)
238
+ tangent_mid = _tangent(segment, (t0 + t1) / 2)
239
+ chord = (p1[0] - p0[0], p1[1] - p0[1])
240
+ if hypot(*tangent0) <= 1e-12:
241
+ tangent0 = chord
242
+ if hypot(*tangent1) <= 1e-12:
243
+ tangent1 = chord
244
+ if hypot(*tangent_mid) <= 1e-12:
245
+ tangent_mid = chord
246
+ normal_change = max(
247
+ _angle_between(tangent0, tangent_mid),
248
+ _angle_between(tangent_mid, tangent1),
249
+ )
250
+ u0 = (segment_index + t0) / segment_count
251
+ u1 = (segment_index + t1) / segment_count
252
+ widths = (width_at(u0), width_at(u1))
253
+ width_error = max(
254
+ abs(
255
+ width_at(u0 + fraction * (u1 - u0))
256
+ - (widths[0] + fraction * (widths[1] - widths[0]))
257
+ )
258
+ for fraction in (0.25, 0.5, 0.75)
259
+ )
260
+ acceptable = (
261
+ chord_error <= config.max_centerline_error
262
+ and normal_change <= config.max_normal_angle_degrees
263
+ and width_error <= config.max_width_error
264
+ )
265
+ if acceptable:
266
+ return [(t0, p0), (t1, p1)]
267
+ if depth >= config.max_subdivision_depth:
268
+ raise SubdivisionLimitError("adaptive outline subdivision exceeded its depth limit")
269
+ middle = (t0 + t1) / 2
270
+ left = subdivide(t0, middle, depth + 1)
271
+ right = subdivide(middle, t1, depth + 1)
272
+ return left[:-1] + right
273
+
274
+ return subdivide(0.0, 1.0, 0)
275
+
276
+
277
+ def _profile_points(
278
+ segments: Sequence[Segment], width_at: Callable[[float], float], config: OutlineConfig
279
+ ) -> tuple[list[Point2D], list[float]]:
280
+ sampled: list[Point2D] = []
281
+ widths: list[float] = []
282
+ count = len(segments)
283
+ for index, segment in enumerate(segments):
284
+ samples = _flatten_segment(segment, index, count, width_at, config)
285
+ for sample_index, (t, point) in enumerate(samples):
286
+ if index and sample_index == 0:
287
+ continue
288
+ u = (index + t) / count
289
+ current_width = float(width_at(u))
290
+ if not isfinite(current_width) or current_width <= 0:
291
+ raise InvalidWidthError("outline widths must be finite and positive")
292
+ sampled.append(point)
293
+ widths.append(current_width)
294
+ return sampled, widths
295
+
296
+
297
+ def _normals(points: list[Point2D], closed: bool) -> list[Point2D]:
298
+ result = []
299
+ count = len(points)
300
+ for index, point in enumerate(points):
301
+ if closed:
302
+ previous = points[(index - 1) % count]
303
+ following = points[(index + 1) % count]
304
+ else:
305
+ previous = points[max(0, index - 1)]
306
+ following = points[min(count - 1, index + 1)]
307
+ tangent = (following[0] - previous[0], following[1] - previous[1])
308
+ if hypot(*tangent) <= 1e-12:
309
+ following = points[min(index + 1, count - 1)]
310
+ tangent = (following[0] - point[0], following[1] - point[1])
311
+ tx, ty = _unit(tangent)
312
+ result.append((-ty, tx))
313
+ return result
314
+
315
+
316
+ def _offset(points: list[Point2D], widths: list[float], closed: bool, side: float) -> list[Point2D]:
317
+ normals = _normals(points, closed)
318
+ offsets = []
319
+ count = len(points)
320
+ for index, (point, normal) in enumerate(zip(points, normals, strict=True)):
321
+ radius = widths[index] / 2
322
+ if closed or 0 < index < count - 1:
323
+ before = normals[(index - 1) % count]
324
+ after = normals[(index + 1) % count]
325
+ bisector = (before[0] + after[0], before[1] + after[1])
326
+ if hypot(*bisector) > 1e-12:
327
+ bisector = _unit(bisector)
328
+ denominator = bisector[0] * normal[0] + bisector[1] * normal[1]
329
+ scale = min(widths[index] / 2 / max(denominator, 0.25), widths[index] * 2)
330
+ normal = (bisector[0] * scale / radius, bisector[1] * scale / radius)
331
+ offsets.append((point[0] + side * radius * normal[0], point[1] + side * radius * normal[1]))
332
+ return offsets
333
+
334
+
335
+ def _arc_points(
336
+ center: Point2D, radius: float, start_angle: float, sweep: float, tolerance: float
337
+ ) -> list[Point2D]:
338
+ if radius <= 0:
339
+ return [center]
340
+ ratio = max(-1.0, min(1.0, 1 - tolerance / radius))
341
+ max_angle = 2 * acos(ratio)
342
+ steps = max(2, ceil(abs(sweep) / max(max_angle, 1e-6)))
343
+ if steps % 2:
344
+ steps += 1
345
+ return [
346
+ (
347
+ center[0] + radius * cos(start_angle + sweep * index / steps),
348
+ center[1] + radius * sin(start_angle + sweep * index / steps),
349
+ )
350
+ for index in range(steps + 1)
351
+ ]
352
+
353
+
354
+ def signed_area(points: Sequence[Point2D]) -> float:
355
+ """Return signed polygon area; the repeated closing point is optional."""
356
+ if len(points) < 3:
357
+ return 0.0
358
+ return (
359
+ sum(
360
+ first[0] * second[1] - second[0] * first[1]
361
+ for first, second in zip(points, (*points[1:], points[0]), strict=False)
362
+ )
363
+ / 2
364
+ )
365
+
366
+
367
+ def winding(points: Sequence[Point2D]) -> Literal["ccw", "cw", "degenerate"]:
368
+ """Return contour winding from signed area."""
369
+ area = signed_area(points)
370
+ if area > 0:
371
+ return "ccw"
372
+ if area < 0:
373
+ return "cw"
374
+ return "degenerate"
375
+
376
+
377
+ def _close(points: list[Point2D]) -> Contour:
378
+ if not points:
379
+ return ()
380
+ if points[-1] != points[0]:
381
+ points.append(points[0])
382
+ if not all(isfinite(value) for point in points for value in point):
383
+ raise OutlineError("outline produced non-finite coordinates")
384
+ return tuple(points)
385
+
386
+
387
+ def _expand_subpath(
388
+ subpath: Subpath,
389
+ width_at: Callable[[float], float],
390
+ config: OutlineConfig,
391
+ ) -> tuple[Contour, ...]:
392
+ segments = subpath.outlining_segments()
393
+ points, widths = _profile_points(segments, width_at, config)
394
+ if len(points) < 2 or any(
395
+ hypot(b[0] - a[0], b[1] - a[1]) <= 1e-12 for a, b in zip(points, points[1:], strict=False)
396
+ ):
397
+ raise DegeneratePathError("outline path contains a zero-length segment")
398
+ if subpath.closed and points[-1] == points[0]:
399
+ points.pop()
400
+ widths.pop()
401
+ left = _offset(points, widths, subpath.closed, 1.0)
402
+ right = _offset(points, widths, subpath.closed, -1.0)
403
+ if subpath.closed:
404
+ left_contour = list(left)
405
+ right_contour = list(right)
406
+ if signed_area(left_contour) * signed_area(right_contour) > 0:
407
+ right_contour.reverse()
408
+ return (_close(left_contour), _close(right_contour))
409
+ if config.cap == "round":
410
+ start_tangent = _unit((points[1][0] - points[0][0], points[1][1] - points[0][1]))
411
+ end_tangent = _unit((points[-1][0] - points[-2][0], points[-1][1] - points[-2][1]))
412
+ end_angle = atan2(end_tangent[1], end_tangent[0])
413
+ start_angle = atan2(start_tangent[1], start_tangent[0])
414
+ end_cap = _arc_points(
415
+ points[-1], widths[-1] / 2, end_angle + pi / 2, -pi, config.max_centerline_error
416
+ )
417
+ start_cap = _arc_points(
418
+ points[0], widths[0] / 2, start_angle - pi / 2, -pi, config.max_centerline_error
419
+ )
420
+ contour = left + end_cap[1:] + list(reversed(right))[1:] + start_cap[1:]
421
+ else:
422
+ start_tangent = _unit((points[1][0] - points[0][0], points[1][1] - points[0][1]))
423
+ end_tangent = _unit((points[-1][0] - points[-2][0], points[-1][1] - points[-2][1]))
424
+ start_extension, end_extension = widths[0] / 2, widths[-1] / 2
425
+ left[0] = _shift(left[0], start_tangent, -start_extension)
426
+ right[0] = _shift(right[0], start_tangent, -start_extension)
427
+ left[-1] = _shift(left[-1], end_tangent, end_extension)
428
+ right[-1] = _shift(right[-1], end_tangent, end_extension)
429
+ contour = left + list(reversed(right))
430
+ return (_close(contour),)
431
+
432
+
433
+ def _result(contours: Sequence[Contour]) -> OutlineResult:
434
+ nonempty = tuple(contour for contour in contours if contour)
435
+ if not nonempty:
436
+ return OutlineResult((), None)
437
+ points = [point for contour in nonempty for point in contour]
438
+ return OutlineResult(
439
+ nonempty,
440
+ (
441
+ min(point[0] for point in points),
442
+ min(point[1] for point in points),
443
+ max(point[0] for point in points),
444
+ max(point[1] for point in points),
445
+ ),
446
+ )
447
+
448
+
449
+ def combine_outlines(outlines: Iterable[OutlineResult]) -> OutlineResult:
450
+ """Aggregate outline contours in order without performing a Boolean union."""
451
+ contours = tuple(contour for result in outlines for contour in result.contours)
452
+ return _result(contours)
453
+
454
+
455
+ def outline(
456
+ path: Path2D | CubicPath,
457
+ width: WidthProfile,
458
+ *,
459
+ config: OutlineConfig = DEFAULT_OUTLINE_CONFIG,
460
+ ) -> OutlineResult:
461
+ """Expand centerlines into deterministic sampled closed contours.
462
+
463
+ Parameters
464
+ ----------
465
+ path : Path2D or planarcurve.CubicPath
466
+ One or more centerline subpaths, or a cubic path accepted directly.
467
+ width : WidthProfile
468
+ A positive full diameter, a piecewise-linear knot sequence spanning
469
+ normalized progress from 0 to 1, or a callable from progress to width.
470
+ config : OutlineConfig, optional
471
+ Adaptive sampling tolerances and open-path cap choice.
472
+
473
+ Returns
474
+ -------
475
+ OutlineResult
476
+ Closed contour tuples and aggregate bounds in
477
+ ``(min_x, min_y, max_x, max_y)`` order.
478
+
479
+ Raises
480
+ ------
481
+ InvalidWidthError
482
+ If the width profile or an evaluated width is invalid.
483
+ DegeneratePathError
484
+ If the centerline contains a zero-length segment or tangent.
485
+ SubdivisionLimitError
486
+ If adaptive sampling cannot meet the configured tolerances within the
487
+ subdivision depth limit.
488
+ TypeError
489
+ If ``path`` is not a ``Path2D`` or ``planarcurve.CubicPath``.
490
+
491
+ Notes
492
+ -----
493
+ Width is the full stroke diameter, not the radius. Normalized width
494
+ progress is segment-index based:
495
+ ``u = (segment_index + local_segment_parameter) / segment_count``.
496
+ It is not physical arc length, and it restarts from zero for every
497
+ independent ``Subpath``. The configured cap applies only to open paths.
498
+ """
499
+ if isinstance(path, CubicPath):
500
+ path = path_from_cubic_path(path)
501
+ if not isinstance(path, Path2D):
502
+ raise TypeError("path must be Path2D or planarcurve.CubicPath")
503
+ width_at = _width_function(width)
504
+ contours = [
505
+ contour
506
+ for subpath in path.subpaths
507
+ for contour in _expand_subpath(subpath, width_at, config)
508
+ ]
509
+ return _result(contours)
510
+
511
+
512
+ def outline_disc(
513
+ center: Point2D,
514
+ diameter: WidthProfile,
515
+ *,
516
+ config: OutlineConfig = DEFAULT_OUTLINE_CONFIG,
517
+ ) -> OutlineResult:
518
+ """Create a sampled circular contour from a center and width profile.
519
+
520
+ Parameters
521
+ ----------
522
+ center : Point2D
523
+ Center of the disc in the input coordinate system.
524
+ diameter : WidthProfile
525
+ Positive full diameter, width knots, or callable width profile.
526
+ config : OutlineConfig, optional
527
+ Sampling tolerance used to choose the circular contour resolution.
528
+
529
+ Returns
530
+ -------
531
+ OutlineResult
532
+ One explicitly closed circular contour and its bounds.
533
+
534
+ Raises
535
+ ------
536
+ InvalidWidthError
537
+ If the width profile or its evaluated value is invalid.
538
+
539
+ Notes
540
+ -----
541
+ Since a point has no path progression, a width profile is evaluated at
542
+ normalized progress ``u == 0.5``.
543
+ """
544
+ width_at = _width_function(diameter)
545
+ diameter_value = width_at(0.5)
546
+ radius = diameter_value / 2
547
+ max_angle = 2 * acos(max(-1.0, 1 - config.max_centerline_error / radius))
548
+ steps = max(8, ceil(2 * pi / max(max_angle, 1e-6)))
549
+ steps = ceil(steps / 4) * 4
550
+ contour = _close(
551
+ [
552
+ (
553
+ center[0] + radius * cos(2 * pi * index / steps),
554
+ center[1] + radius * sin(2 * pi * index / steps),
555
+ )
556
+ for index in range(steps)
557
+ ]
558
+ )
559
+ return _result((contour,))
560
+
561
+
562
+ # Migration-friendly alias for InkGrammar terminology.
563
+ expand_tap = outline_disc
strokeoutline/model.py ADDED
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from math import isfinite
6
+ from typing import TypeAlias
7
+
8
+ from planarcurve import CubicBezier, CubicPath, Point2D, QuadraticBezier
9
+
10
+
11
+ def _point(value: Sequence[float], *, name: str) -> Point2D:
12
+ if len(value) != 2:
13
+ raise ValueError(f"{name} must have exactly two coordinates")
14
+ point = (float(value[0]), float(value[1]))
15
+ if not all(isfinite(component) for component in point):
16
+ raise ValueError(f"{name} must contain finite coordinates")
17
+ return point
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Line:
22
+ """Finite straight segment between two 2D endpoints.
23
+
24
+ Parameters
25
+ ----------
26
+ p0 : Point2D
27
+ Start coordinate.
28
+ p1 : Point2D
29
+ End coordinate.
30
+
31
+ Both endpoints must contain exactly two finite coordinates. A zero-length
32
+ line can be constructed, but ``outline()`` rejects it as degenerate.
33
+ """
34
+
35
+ p0: Point2D
36
+ p1: Point2D
37
+
38
+ def __post_init__(self) -> None:
39
+ object.__setattr__(self, "p0", _point(self.p0, name="p0"))
40
+ object.__setattr__(self, "p1", _point(self.p1, name="p1"))
41
+
42
+
43
+ Segment: TypeAlias = Line | QuadraticBezier | CubicBezier
44
+
45
+
46
+ def segment_start(segment: Segment) -> Point2D:
47
+ return segment.p0
48
+
49
+
50
+ def segment_end(segment: Segment) -> Point2D:
51
+ if isinstance(segment, Line):
52
+ return segment.p1
53
+ if isinstance(segment, QuadraticBezier):
54
+ return segment.p2
55
+ return segment.p3
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Subpath:
60
+ """One ordered, connected centerline subpath.
61
+
62
+ Parameters
63
+ ----------
64
+ segments : Sequence[Segment]
65
+ Non-empty ordered sequence of ``Line``, ``QuadraticBezier``, or
66
+ ``CubicBezier`` segments. Adjacent endpoints must compare exactly equal.
67
+ closed : bool, optional
68
+ If true, add an implicit closing ``Line`` during outlining when the
69
+ final endpoint differs from the first start point.
70
+
71
+ Raises
72
+ ------
73
+ ValueError
74
+ If no segments are provided or adjacent segments are disconnected.
75
+ TypeError
76
+ If a segment has an unsupported type.
77
+
78
+ Notes
79
+ -----
80
+ Repeating the start point with an explicit segment is not required for a
81
+ closed subpath. Outlining a zero-length segment raises
82
+ ``DegeneratePathError``.
83
+ """
84
+
85
+ segments: Sequence[Segment]
86
+ closed: bool = False
87
+
88
+ def __post_init__(self) -> None:
89
+ segments = tuple(self.segments)
90
+ if not segments:
91
+ raise ValueError("subpath requires at least one segment")
92
+ allowed = (Line, QuadraticBezier, CubicBezier)
93
+ if not all(isinstance(segment, allowed) for segment in segments):
94
+ raise TypeError("segments must contain Line, QuadraticBezier, or CubicBezier values")
95
+ for left, right in zip(segments, segments[1:], strict=False):
96
+ if segment_end(left) != segment_start(right):
97
+ raise ValueError("subpath segments must be connected in order")
98
+ object.__setattr__(self, "segments", segments)
99
+
100
+ def outlining_segments(self) -> tuple[Segment, ...]:
101
+ segments = tuple(self.segments)
102
+ if self.closed:
103
+ start = segment_start(segments[0])
104
+ end = segment_end(segments[-1])
105
+ if end != start:
106
+ segments = (*segments, Line(end, start))
107
+ return segments
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Path2D:
112
+ """A path containing one or more independent centerline subpaths.
113
+
114
+ Parameters
115
+ ----------
116
+ subpaths : Sequence[Subpath]
117
+ Non-empty sequence of subpaths to outline independently.
118
+
119
+ Raises
120
+ ------
121
+ ValueError
122
+ If no subpaths are provided.
123
+ TypeError
124
+ If an element is not a ``Subpath``.
125
+
126
+ Notes
127
+ -----
128
+ Each subpath receives a separate outline, and normalized width progress
129
+ restarts at zero for each one.
130
+ """
131
+
132
+ subpaths: Sequence[Subpath]
133
+
134
+ def __post_init__(self) -> None:
135
+ subpaths = tuple(self.subpaths)
136
+ if not subpaths:
137
+ raise ValueError("path requires at least one subpath")
138
+ if not all(isinstance(subpath, Subpath) for subpath in subpaths):
139
+ raise TypeError("subpaths must contain Subpath values")
140
+ object.__setattr__(self, "subpaths", subpaths)
141
+
142
+
143
+ def path_from_cubic_path(path: CubicPath, *, closed: bool = False) -> Path2D:
144
+ """Adapt a :class:`planarcurve.CubicPath` into a ``Path2D``.
145
+
146
+ Parameters
147
+ ----------
148
+ path : planarcurve.CubicPath
149
+ Cubic path to adapt.
150
+ closed : bool, optional
151
+ Whether the adapted subpath should be implicitly closed during outlining.
152
+
153
+ Returns
154
+ -------
155
+ Path2D
156
+ A path containing one subpath with the cubic path's Bézier segments.
157
+
158
+ Raises
159
+ ------
160
+ ValueError
161
+ If the cubic path contains no segments.
162
+ """
163
+ beziers = path.to_beziers()
164
+ if not beziers:
165
+ raise ValueError("cubic path requires at least one segment")
166
+ return Path2D((Subpath(beziers, closed=closed),))
167
+
168
+
169
+ def path_from_polyline(points: Sequence[Sequence[float]], *, closed: bool = False) -> Path2D:
170
+ """Build a path from ordered finite 2D points.
171
+
172
+ Parameters
173
+ ----------
174
+ points : Sequence[Sequence[float]]
175
+ At least two finite ``(x, y)`` coordinates, in path order.
176
+ closed : bool, optional
177
+ If true, close the path implicitly during outlining; the first point
178
+ need not be repeated.
179
+
180
+ Returns
181
+ -------
182
+ Path2D
183
+ A path containing one subpath made from consecutive line segments.
184
+
185
+ Raises
186
+ ------
187
+ ValueError
188
+ If fewer than two points are supplied or a point is not a finite pair.
189
+ TypeError
190
+ If point coordinates cannot be interpreted as numeric values.
191
+
192
+ Notes
193
+ -----
194
+ Repeated adjacent points are retained. Outlining a resulting zero-length
195
+ segment raises ``DegeneratePathError``.
196
+ """
197
+ finite_points = tuple(
198
+ _point(point, name=f"points[{index}]") for index, point in enumerate(points)
199
+ )
200
+ if len(finite_points) < 2:
201
+ raise ValueError("polyline requires at least two points")
202
+ segments = tuple(
203
+ Line(start, end) for start, end in zip(finite_points, finite_points[1:], strict=False)
204
+ )
205
+ return Path2D((Subpath(segments, closed=closed),))
strokeoutline/py.typed ADDED
File without changes
strokeoutline/svg.py ADDED
@@ -0,0 +1,144 @@
1
+ """Optional SVG path adapter based on FontTools.
2
+
3
+ The core package does not require FontTools. This adapter intentionally owns
4
+ only the compatibility bridge needed for the initial InkGrammar extraction.
5
+ A future planarcurve public parsed-path API can replace this parser without
6
+ changing the core outlining API.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ try:
12
+ from fontTools.pens.recordingPen import RecordingPen
13
+ from fontTools.svgLib.path import parse_path
14
+ except ImportError as exc: # pragma: no cover
15
+ raise ImportError("strokeoutline.svg requires FontTools; install strokeoutline[svg]") from exc
16
+
17
+ from planarcurve import CubicBezier, QuadraticBezier
18
+
19
+ from .core import DEFAULT_OUTLINE_CONFIG, OutlineConfig, OutlineResult, WidthProfile, outline
20
+ from .model import Line, Path2D, Subpath
21
+
22
+
23
+ def path_from_svg(path: str) -> Path2D:
24
+ """Parse SVG path data into StrokeOutline's neutral path model.
25
+
26
+ Parameters
27
+ ----------
28
+ path : str
29
+ SVG path data to parse.
30
+
31
+ Returns
32
+ -------
33
+ Path2D
34
+ The parsed line, quadratic, and cubic subpaths.
35
+
36
+ Raises
37
+ ------
38
+ ValueError
39
+ If the data contains no drawable segments or an unsupported/invalid
40
+ path operation.
41
+
42
+ Notes
43
+ -----
44
+ This function requires FontTools from the optional ``svg`` extra. It parses
45
+ path geometry only; it does not render SVG/CSS strokes.
46
+ """
47
+ pen = RecordingPen()
48
+ parse_path(path, pen)
49
+ if not pen.value:
50
+ raise ValueError("path produced no operations")
51
+
52
+ subpaths: list[Subpath] = []
53
+ segments = []
54
+ current = None
55
+ start = None
56
+
57
+ def finish(*, closed: bool) -> None:
58
+ nonlocal segments
59
+ if segments:
60
+ subpaths.append(Subpath(tuple(segments), closed=closed))
61
+ segments = []
62
+
63
+ for operator, args in pen.value:
64
+ if operator == "moveTo":
65
+ finish(closed=False)
66
+ current = start = tuple(map(float, args[0]))
67
+ elif operator == "lineTo":
68
+ if current is None:
69
+ raise ValueError("outline path draws before moveTo")
70
+ end = tuple(map(float, args[0]))
71
+ segments.append(Line(current, end))
72
+ current = end
73
+ elif operator == "curveTo":
74
+ if current is None or len(args) != 3:
75
+ raise ValueError("outline path has an invalid cubic segment")
76
+ c1, c2, end = (tuple(map(float, value)) for value in args)
77
+ segments.append(CubicBezier(current, c1, c2, end))
78
+ current = end
79
+ elif operator == "qCurveTo":
80
+ if current is None or len(args) != 2 or args[-1] is None:
81
+ raise ValueError("outline path has an invalid quadratic segment")
82
+ control, end = (tuple(map(float, value)) for value in args)
83
+ segments.append(QuadraticBezier(current, control, end))
84
+ current = end
85
+ elif operator == "closePath":
86
+ if current is None or start is None:
87
+ raise ValueError("outline path closes before moveTo")
88
+ finish(closed=True)
89
+ current = start = None
90
+ elif operator == "endPath":
91
+ finish(closed=False)
92
+ current = start = None
93
+ else:
94
+ raise ValueError(f"unsupported outline path operation: {operator}")
95
+
96
+ finish(closed=False)
97
+ if not subpaths:
98
+ raise ValueError("outline path has no drawable segments")
99
+ return Path2D(tuple(subpaths))
100
+
101
+
102
+ def outline_svg_path(
103
+ path: str,
104
+ width: WidthProfile,
105
+ *,
106
+ config: OutlineConfig = DEFAULT_OUTLINE_CONFIG,
107
+ ) -> OutlineResult:
108
+ """Parse and expand SVG path data into closed sampled contours.
109
+
110
+ Parameters
111
+ ----------
112
+ path : str
113
+ SVG path data to parse.
114
+ width : WidthProfile
115
+ Constant, piecewise-linear, or callable full-diameter width profile.
116
+ config : OutlineConfig, optional
117
+ Adaptive sampling tolerances and open-path cap selection.
118
+
119
+ Returns
120
+ -------
121
+ OutlineResult
122
+ Closed contour tuples and aggregate bounds.
123
+
124
+ Raises
125
+ ------
126
+ ValueError
127
+ If SVG path data cannot be converted to the supported neutral model.
128
+ InvalidWidthError
129
+ If the width profile is invalid.
130
+ DegeneratePathError
131
+ If the parsed centerline contains degenerate geometry.
132
+ SubdivisionLimitError
133
+ If adaptive sampling cannot meet the configured tolerances.
134
+
135
+ Notes
136
+ -----
137
+ Importing this module requires FontTools from the optional ``svg`` extra.
138
+ The adapter parses path geometry; it is not a complete SVG/CSS stroker.
139
+ """
140
+ return outline(path_from_svg(path), width, config=config)
141
+
142
+
143
+ # Compatibility name matching InkGrammar's previous generic entry point.
144
+ expand_path = outline_svg_path
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: strokeoutline
3
+ Version: 0.1.0
4
+ Summary: Deterministic variable-width stroke expansion from 2D centerlines to filled contours.
5
+ Author: Holger Nahrstaedt
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/schreibatlas/strokeoutline
8
+ Project-URL: Repository, https://github.com/schreibatlas/strokeoutline
9
+ Project-URL: Issues, https://github.com/schreibatlas/strokeoutline/issues
10
+ Project-URL: Changelog, https://github.com/schreibatlas/strokeoutline/releases
11
+ Keywords: geometry,stroke,outline,offset-curve,bezier,vector-graphics,svg
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Multimedia :: Graphics
22
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: planarcurve<0.2,>=0.1
28
+ Provides-Extra: svg
29
+ Requires-Dist: fonttools>=4.55; extra == "svg"
30
+ Provides-Extra: dev
31
+ Requires-Dist: build>=1.2; extra == "dev"
32
+ Requires-Dist: fonttools>=4.55; extra == "dev"
33
+ Requires-Dist: pytest>=8; extra == "dev"
34
+ Requires-Dist: ruff>=0.8; extra == "dev"
35
+ Requires-Dist: twine>=5; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # strokeoutline
39
+
40
+ `strokeoutline` converts 2D centerlines into deterministic filled contours with constant or variable width. It is reusable geometry extracted from InkGrammar's stroke outlining engine, without glyph, shaping, OCR, or handwriting-model responsibilities.
41
+
42
+ Use it for vector brushes, pressure-like width profiles, centerline fonts, skeleton thickening, corridors, and other centerline-based applications. The MVP preserves current geometry behavior; see the documentation for its limitations and compatibility contracts.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ python -m pip install strokeoutline
48
+ ```
49
+
50
+ For SVG path parsing, install the optional extra:
51
+
52
+ ```bash
53
+ python -m pip install 'strokeoutline[svg]'
54
+ ```
55
+
56
+ ## Documentation
57
+
58
+ The complete guides and deliberate API reference are in the [documentation source](docs/index.md):
59
+
60
+ - [Getting started](docs/getting-started.md)
61
+ - [Examples](docs/examples.md)
62
+ - [API reference](docs/api/index.md)
63
+ - [Limitations](docs/limitations.md)
64
+
65
+ ## Core example
66
+
67
+ ```python
68
+ from strokeoutline import Line, Path2D, Subpath, outline
69
+
70
+ path = Path2D((Subpath((Line((0, 0), (10, 0)),)),))
71
+
72
+ result = outline(path, width=4.0)
73
+ print(result.bounds)
74
+ for contour in result.contours:
75
+ print(contour)
76
+ ```
77
+
78
+ Width is the full stroke diameter, not radius. Width profiles may be a positive constant, a strictly increasing piecewise-linear knot sequence spanning `0..1`, or a callable.
79
+
80
+ For compatibility, normalized width progress is based on segment index, not physical arc length:
81
+
82
+ ```text
83
+ u = (segment_index + local_segment_parameter) / segment_count
84
+ ```
85
+
86
+ Progress restarts at zero for each independent subpath. See [width profiles](docs/width-profiles.md) for details.
87
+
88
+ ## Integration boundary
89
+
90
+ StrokeOutline owns centerline-to-contour geometry. Integrations such as InkGrammar own shaped-text traversal and domain-specific adaptation, and should call StrokeOutline rather than copy its outlining algorithm. The core package does not require FontTools; only the optional `strokeoutline.svg` adapter does.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ python -m pip install -e '.[dev]'
96
+ ruff format --check .
97
+ ruff check .
98
+ pytest
99
+ python docs/make.py html
100
+ python docs/make.py doctest
101
+ ```
102
+
103
+ ## License
104
+
105
+ Apache-2.0.
@@ -0,0 +1,11 @@
1
+ strokeoutline/__init__.py,sha256=JAxSCVHcxruzD2bHWUoLY5_-WNaJfkx8V7NmYjW6dN4,1360
2
+ strokeoutline/_version.py,sha256=lycdDSVOV74R332ezrzWfEthUQ4KGwAk2Z0XRudpMyY,528
3
+ strokeoutline/core.py,sha256=ch0FqybTRSVEgnbndtMifUlu2qLIIAseOVXAjKc0jKY,20988
4
+ strokeoutline/model.py,sha256=waujNObbiN31RFA0zgm8ShllG-rscPGm9uEQq16HI0c,6292
5
+ strokeoutline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ strokeoutline/svg.py,sha256=cRcECcc2drRwCoo2mdTTx8eOdwxVPTHnZ16Vd0aP6Ls,4748
7
+ strokeoutline-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
+ strokeoutline-0.1.0.dist-info/METADATA,sha256=cFOwDlLodzZiBEYJVqYQhzXzCnZxXHHNvtR8J8eQKiM,3792
9
+ strokeoutline-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ strokeoutline-0.1.0.dist-info/top_level.txt,sha256=JfubmOIq0vscSgw09jvRbOl3W3i8qT54TTxzBKbj4h4,14
11
+ strokeoutline-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ strokeoutline