Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - DrawingML Preset Shapes
|
||||
|
||||
Shared full-catalog loader, registry, and formula evaluator for PPTX shapes.
|
||||
|
||||
Usage:
|
||||
Import get_preset_registry from pptx_shapes.
|
||||
|
||||
Examples:
|
||||
from pptx_shapes import get_preset_registry
|
||||
arrow = get_preset_registry().evaluate("rightArrow", 320, 160)
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
from .errors import (
|
||||
FormulaEvaluationError,
|
||||
FormulaSyntaxError,
|
||||
PresetShapeDataError,
|
||||
PresetShapeError,
|
||||
UnknownGuideError,
|
||||
UnknownPresetShapeError,
|
||||
)
|
||||
from .formula import (
|
||||
FULL_CIRCLE,
|
||||
OOXML_COORDINATE_MAX,
|
||||
OOXML_COORDINATE_MIN,
|
||||
OOXML_DEGREE,
|
||||
OOXML_LINE_WIDTH_MAX,
|
||||
SUPPORTED_OPERATORS,
|
||||
FormulaEvaluator,
|
||||
build_builtin_guides,
|
||||
evaluate_formula,
|
||||
validate_ooxml_line_width,
|
||||
validate_ooxml_xfrm,
|
||||
)
|
||||
from .loader import (
|
||||
BUNDLED_DEFINITIONS_PATH,
|
||||
BUNDLED_DEFINITIONS_SHA256,
|
||||
BUNDLED_SHAPE_TYPES_PATH,
|
||||
BUNDLED_SHAPE_TYPES_SHA256,
|
||||
DRAWINGML_NS,
|
||||
EXPECTED_SHAPE_COUNT,
|
||||
load_preset_shape_definitions,
|
||||
load_shape_type_values,
|
||||
)
|
||||
from .models import (
|
||||
EvaluatedPresetGeometry,
|
||||
PresetShapeDefinition,
|
||||
)
|
||||
from .registry import (
|
||||
CONNECTOR_PRESET_TYPES,
|
||||
PresetShapeRegistry,
|
||||
get_preset_registry,
|
||||
)
|
||||
from .semantic_hash import (
|
||||
NATIVE_FALLBACK_SHA256_ATTR,
|
||||
resolve_preset_preview_hash,
|
||||
svg_native_fallback_fingerprint,
|
||||
svg_native_fallback_markup_fingerprint,
|
||||
svg_preset_preview_fingerprint,
|
||||
svg_text_fingerprint,
|
||||
)
|
||||
from .xml_safety import RELATIONSHIPS_NS, has_relationship_attributes
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BUNDLED_DEFINITIONS_PATH",
|
||||
"BUNDLED_DEFINITIONS_SHA256",
|
||||
"BUNDLED_SHAPE_TYPES_PATH",
|
||||
"BUNDLED_SHAPE_TYPES_SHA256",
|
||||
"CONNECTOR_PRESET_TYPES",
|
||||
"DRAWINGML_NS",
|
||||
"EXPECTED_SHAPE_COUNT",
|
||||
"FULL_CIRCLE",
|
||||
"OOXML_COORDINATE_MAX",
|
||||
"OOXML_COORDINATE_MIN",
|
||||
"OOXML_DEGREE",
|
||||
"OOXML_LINE_WIDTH_MAX",
|
||||
"NATIVE_FALLBACK_SHA256_ATTR",
|
||||
"SUPPORTED_OPERATORS",
|
||||
"EvaluatedPresetGeometry",
|
||||
"FormulaEvaluationError",
|
||||
"FormulaEvaluator",
|
||||
"FormulaSyntaxError",
|
||||
"PresetShapeDataError",
|
||||
"PresetShapeDefinition",
|
||||
"PresetShapeError",
|
||||
"PresetShapeRegistry",
|
||||
"RELATIONSHIPS_NS",
|
||||
"UnknownGuideError",
|
||||
"UnknownPresetShapeError",
|
||||
"build_builtin_guides",
|
||||
"evaluate_formula",
|
||||
"get_preset_registry",
|
||||
"has_relationship_attributes",
|
||||
"load_preset_shape_definitions",
|
||||
"load_shape_type_values",
|
||||
"resolve_preset_preview_hash",
|
||||
"svg_native_fallback_fingerprint",
|
||||
"svg_native_fallback_markup_fingerprint",
|
||||
"svg_text_fingerprint",
|
||||
"svg_preset_preview_fingerprint",
|
||||
"validate_ooxml_line_width",
|
||||
"validate_ooxml_xfrm",
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,23 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) .NET Foundation and Contributors
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,54 @@
|
||||
# DrawingML preset geometry data provenance
|
||||
|
||||
## Apache POI notice
|
||||
|
||||
Apache POI
|
||||
|
||||
Copyright 2003-2025 The Apache Software Foundation
|
||||
|
||||
This product includes software developed at
|
||||
|
||||
The Apache Software Foundation (<https://www.apache.org/>).
|
||||
|
||||
## Vendored data
|
||||
|
||||
`presetShapeDefinitions.xml` is vendored from Apache POI 5.4.1:
|
||||
|
||||
- Release tag: `REL_5_4_1`
|
||||
- Source commit: `4554f204cbbf00ecbcaed134fe57e43a1779a612`
|
||||
- Source path: `poi/src/main/resources/org/apache/poi/sl/draw/geom/presetShapeDefinitions.xml`
|
||||
- Source revision: <https://github.com/apache/poi/tree/4554f204cbbf00ecbcaed134fe57e43a1779a612>
|
||||
- Original release-file SHA-256:
|
||||
`a7dad593d27bd70536b41da9b761fa16409536cc0c25ef2b6c7a61c5d9b3e738`
|
||||
- Vendored SHA-256: `4a762444d8d85876881c02a5b1dedf6f73006fcd8acb7b4e393435615b37c780`
|
||||
- Modification: line endings normalized from the release JAR's mixed CRLF/LF to LF; XML content is otherwise unchanged.
|
||||
- License: Apache License 2.0; see `LICENSE-APACHE-2.0.txt`.
|
||||
|
||||
The independent completeness list in `shape_type_values.txt` is derived from
|
||||
the Open XML SDK schema metadata for `a:ST_ShapeType` / `ShapeTypeValues`:
|
||||
|
||||
- Repository: `dotnet/Open-XML-SDK`
|
||||
- Source commit: `00967dc871f06776ae969762c6703d062308a6c9`
|
||||
- Source path: `data/schemas/schemas_openxmlformats_org_drawingml_2006_main.json`
|
||||
- Source revision:
|
||||
<https://github.com/dotnet/Open-XML-SDK/tree/00967dc871f06776ae969762c6703d062308a6c9>
|
||||
- Source-file SHA-256:
|
||||
`e760b534c96ee02745d2a8084f1be362826da4470a85d0b0102a67c3b1678ab7`
|
||||
- Vendored enum-list SHA-256:
|
||||
`f2c3bdcda8569b358ce3196cfeb183849e33bfc7955fac961dc85fceb6b3b587`
|
||||
- License: MIT (`dotnet/Open-XML-SDK`); see
|
||||
`LICENSE-OPEN-XML-SDK-MIT.txt`.
|
||||
|
||||
At the locked revisions, both sources contain exactly 187 unique preset names,
|
||||
and their sets are identical.
|
||||
|
||||
## Evaluator compatibility notes
|
||||
|
||||
- The circular-arrow, left-circular-arrow, and left-right-circular-arrow
|
||||
definitions each contain `+- xH 0 dxB 0`. Apache POI evaluates the first
|
||||
three operands and ignores the inert final zero. The PPT Master evaluator
|
||||
accepts only this trailing-zero compatibility form; other arity mismatches
|
||||
remain errors.
|
||||
- A small number of definitions rebind an intermediate guide name later in
|
||||
the ordered `gdLst`. Evaluation therefore preserves document order and the
|
||||
later value replaces the earlier value, matching Apache POI behavior.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
line
|
||||
lineInv
|
||||
triangle
|
||||
rtTriangle
|
||||
rect
|
||||
diamond
|
||||
parallelogram
|
||||
trapezoid
|
||||
nonIsoscelesTrapezoid
|
||||
pentagon
|
||||
hexagon
|
||||
heptagon
|
||||
octagon
|
||||
decagon
|
||||
dodecagon
|
||||
star4
|
||||
star5
|
||||
star6
|
||||
star7
|
||||
star8
|
||||
star10
|
||||
star12
|
||||
star16
|
||||
star24
|
||||
star32
|
||||
roundRect
|
||||
round1Rect
|
||||
round2SameRect
|
||||
round2DiagRect
|
||||
snipRoundRect
|
||||
snip1Rect
|
||||
snip2SameRect
|
||||
snip2DiagRect
|
||||
plaque
|
||||
ellipse
|
||||
teardrop
|
||||
homePlate
|
||||
chevron
|
||||
pieWedge
|
||||
pie
|
||||
blockArc
|
||||
donut
|
||||
noSmoking
|
||||
rightArrow
|
||||
leftArrow
|
||||
upArrow
|
||||
downArrow
|
||||
stripedRightArrow
|
||||
notchedRightArrow
|
||||
bentUpArrow
|
||||
leftRightArrow
|
||||
upDownArrow
|
||||
leftUpArrow
|
||||
leftRightUpArrow
|
||||
quadArrow
|
||||
leftArrowCallout
|
||||
rightArrowCallout
|
||||
upArrowCallout
|
||||
downArrowCallout
|
||||
leftRightArrowCallout
|
||||
upDownArrowCallout
|
||||
quadArrowCallout
|
||||
bentArrow
|
||||
uturnArrow
|
||||
circularArrow
|
||||
leftCircularArrow
|
||||
leftRightCircularArrow
|
||||
curvedRightArrow
|
||||
curvedLeftArrow
|
||||
curvedUpArrow
|
||||
curvedDownArrow
|
||||
swooshArrow
|
||||
cube
|
||||
can
|
||||
lightningBolt
|
||||
heart
|
||||
sun
|
||||
moon
|
||||
smileyFace
|
||||
irregularSeal1
|
||||
irregularSeal2
|
||||
foldedCorner
|
||||
bevel
|
||||
frame
|
||||
halfFrame
|
||||
corner
|
||||
diagStripe
|
||||
chord
|
||||
arc
|
||||
leftBracket
|
||||
rightBracket
|
||||
leftBrace
|
||||
rightBrace
|
||||
bracketPair
|
||||
bracePair
|
||||
straightConnector1
|
||||
bentConnector2
|
||||
bentConnector3
|
||||
bentConnector4
|
||||
bentConnector5
|
||||
curvedConnector2
|
||||
curvedConnector3
|
||||
curvedConnector4
|
||||
curvedConnector5
|
||||
callout1
|
||||
callout2
|
||||
callout3
|
||||
accentCallout1
|
||||
accentCallout2
|
||||
accentCallout3
|
||||
borderCallout1
|
||||
borderCallout2
|
||||
borderCallout3
|
||||
accentBorderCallout1
|
||||
accentBorderCallout2
|
||||
accentBorderCallout3
|
||||
wedgeRectCallout
|
||||
wedgeRoundRectCallout
|
||||
wedgeEllipseCallout
|
||||
cloudCallout
|
||||
cloud
|
||||
ribbon
|
||||
ribbon2
|
||||
ellipseRibbon
|
||||
ellipseRibbon2
|
||||
leftRightRibbon
|
||||
verticalScroll
|
||||
horizontalScroll
|
||||
wave
|
||||
doubleWave
|
||||
plus
|
||||
flowChartProcess
|
||||
flowChartDecision
|
||||
flowChartInputOutput
|
||||
flowChartPredefinedProcess
|
||||
flowChartInternalStorage
|
||||
flowChartDocument
|
||||
flowChartMultidocument
|
||||
flowChartTerminator
|
||||
flowChartPreparation
|
||||
flowChartManualInput
|
||||
flowChartManualOperation
|
||||
flowChartConnector
|
||||
flowChartPunchedCard
|
||||
flowChartPunchedTape
|
||||
flowChartSummingJunction
|
||||
flowChartOr
|
||||
flowChartCollate
|
||||
flowChartSort
|
||||
flowChartExtract
|
||||
flowChartMerge
|
||||
flowChartOfflineStorage
|
||||
flowChartOnlineStorage
|
||||
flowChartMagneticTape
|
||||
flowChartMagneticDisk
|
||||
flowChartMagneticDrum
|
||||
flowChartDisplay
|
||||
flowChartDelay
|
||||
flowChartAlternateProcess
|
||||
flowChartOffpageConnector
|
||||
actionButtonBlank
|
||||
actionButtonHome
|
||||
actionButtonHelp
|
||||
actionButtonInformation
|
||||
actionButtonForwardNext
|
||||
actionButtonBackPrevious
|
||||
actionButtonEnd
|
||||
actionButtonBeginning
|
||||
actionButtonReturn
|
||||
actionButtonDocument
|
||||
actionButtonSound
|
||||
actionButtonMovie
|
||||
gear6
|
||||
gear9
|
||||
funnel
|
||||
mathPlus
|
||||
mathMinus
|
||||
mathMultiply
|
||||
mathDivide
|
||||
mathEqual
|
||||
mathNotEqual
|
||||
cornerTabs
|
||||
squareTabs
|
||||
plaqueTabs
|
||||
chartX
|
||||
chartStar
|
||||
chartPlus
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - PPTX Shape Errors
|
||||
|
||||
Shared exception types for the DrawingML preset-geometry package.
|
||||
|
||||
Usage:
|
||||
Import exception classes from pptx_shapes.errors.
|
||||
|
||||
Examples:
|
||||
from pptx_shapes.errors import FormulaEvaluationError
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
|
||||
class PresetShapeError(ValueError):
|
||||
"""Base error for invalid preset-shape data or evaluation input."""
|
||||
|
||||
|
||||
class PresetShapeDataError(PresetShapeError):
|
||||
"""Raised when the bundled preset catalog is incomplete or malformed."""
|
||||
|
||||
|
||||
class FormulaSyntaxError(PresetShapeError):
|
||||
"""Raised when a DrawingML guide formula has invalid syntax."""
|
||||
|
||||
|
||||
class FormulaEvaluationError(PresetShapeError):
|
||||
"""Raised when a syntactically valid formula cannot be evaluated."""
|
||||
|
||||
|
||||
class UnknownGuideError(PresetShapeError):
|
||||
"""Raised when a formula references an unknown guide token."""
|
||||
|
||||
|
||||
class UnknownPresetShapeError(KeyError):
|
||||
"""Raised when a preset name is absent from the locked shape catalog."""
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - DrawingML Formula Evaluator
|
||||
|
||||
Evaluate DrawingML geometry guide formulas and standard built-in guides.
|
||||
|
||||
Usage:
|
||||
Import FormulaEvaluator or evaluate_formula from pptx_shapes.formula.
|
||||
|
||||
Examples:
|
||||
evaluator = FormulaEvaluator(width=200, height=100)
|
||||
evaluator.evaluate("*/ w 1 2")
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
from typing import Mapping
|
||||
|
||||
from .errors import (
|
||||
FormulaEvaluationError,
|
||||
FormulaSyntaxError,
|
||||
UnknownGuideError,
|
||||
)
|
||||
|
||||
|
||||
OOXML_DEGREE = 60000.0
|
||||
FULL_CIRCLE = 360.0 * OOXML_DEGREE
|
||||
OOXML_COORDINATE_MIN = -27273042329600
|
||||
OOXML_COORDINATE_MAX = 27273042316900
|
||||
OOXML_LINE_WIDTH_MAX = 20116800
|
||||
|
||||
_OPERATOR_ARITY = {
|
||||
"val": 1,
|
||||
"*/": 3,
|
||||
"+-": 3,
|
||||
"+/": 3,
|
||||
"?:": 3,
|
||||
"abs": 1,
|
||||
"at2": 2,
|
||||
"cat2": 3,
|
||||
"cos": 2,
|
||||
"max": 2,
|
||||
"min": 2,
|
||||
"mod": 3,
|
||||
"pin": 3,
|
||||
"sat2": 3,
|
||||
"sin": 2,
|
||||
"sqrt": 1,
|
||||
"tan": 2,
|
||||
}
|
||||
SUPPORTED_OPERATORS = frozenset(_OPERATOR_ARITY)
|
||||
|
||||
_DIMENSION_GUIDE_RE = re.compile(r"^(wd|hd|ssd)([1-9][0-9]*)$")
|
||||
_ANGLE_GUIDE_RE = re.compile(r"^(?:(\d+))?cd([1-9][0-9]*)$")
|
||||
|
||||
|
||||
def validate_ooxml_xfrm(
|
||||
off_x: int,
|
||||
off_y: int,
|
||||
ext_cx: int,
|
||||
ext_cy: int,
|
||||
) -> None:
|
||||
"""Validate DrawingML shape offsets and extents in EMU."""
|
||||
for name, value in (("x", off_x), ("y", off_y)):
|
||||
if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX:
|
||||
raise ValueError(
|
||||
f"DrawingML xfrm offset {name}={value} is outside the "
|
||||
"OOXML coordinate range"
|
||||
)
|
||||
for name, value in (("cx", ext_cx), ("cy", ext_cy)):
|
||||
if value < 0 or value > OOXML_COORDINATE_MAX:
|
||||
raise ValueError(
|
||||
f"DrawingML xfrm extent {name}={value} is outside the "
|
||||
"OOXML positive-coordinate range"
|
||||
)
|
||||
|
||||
|
||||
def validate_ooxml_line_width(width: int) -> None:
|
||||
"""Validate one DrawingML ``ST_LineWidth`` value in EMU."""
|
||||
if width < 0 or width > OOXML_LINE_WIDTH_MAX:
|
||||
raise ValueError(
|
||||
f"DrawingML line width {width} is outside the OOXML line-width range"
|
||||
)
|
||||
|
||||
|
||||
def build_builtin_guides(
|
||||
width: float,
|
||||
height: float,
|
||||
*,
|
||||
left: float = 0.0,
|
||||
top: float = 0.0,
|
||||
) -> Mapping[str, float]:
|
||||
"""Build the standard DrawingML geometry guide context for one frame."""
|
||||
|
||||
width = _finite_number(width, "width")
|
||||
height = _finite_number(height, "height")
|
||||
left = _finite_number(left, "left")
|
||||
top = _finite_number(top, "top")
|
||||
if width < 0 or height < 0:
|
||||
raise FormulaEvaluationError("Shape width and height must be non-negative")
|
||||
|
||||
right = left + width
|
||||
bottom = top + height
|
||||
short_side = min(width, height)
|
||||
long_side = max(width, height)
|
||||
values = {
|
||||
"l": left,
|
||||
"t": top,
|
||||
"r": right,
|
||||
"b": bottom,
|
||||
"w": width,
|
||||
"h": height,
|
||||
"hc": left + width / 2.0,
|
||||
"vc": top + height / 2.0,
|
||||
"ss": short_side,
|
||||
"ls": long_side,
|
||||
"cd2": FULL_CIRCLE / 2.0,
|
||||
"cd3": FULL_CIRCLE / 3.0,
|
||||
"cd4": FULL_CIRCLE / 4.0,
|
||||
"cd8": FULL_CIRCLE / 8.0,
|
||||
"3cd4": FULL_CIRCLE * 3.0 / 4.0,
|
||||
"3cd8": FULL_CIRCLE * 3.0 / 8.0,
|
||||
"5cd8": FULL_CIRCLE * 5.0 / 8.0,
|
||||
"7cd8": FULL_CIRCLE * 7.0 / 8.0,
|
||||
}
|
||||
for divisor in (2, 3, 4, 5, 6, 8, 10, 12, 16, 32):
|
||||
values[f"wd{divisor}"] = width / divisor
|
||||
values[f"hd{divisor}"] = height / divisor
|
||||
values[f"ssd{divisor}"] = short_side / divisor
|
||||
return MappingProxyType(values)
|
||||
|
||||
|
||||
class FormulaEvaluator:
|
||||
"""Evaluate guide formulas against a shape-local DrawingML context."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: float,
|
||||
height: float,
|
||||
*,
|
||||
left: float = 0.0,
|
||||
top: float = 0.0,
|
||||
values: Mapping[str, float] | None = None,
|
||||
) -> None:
|
||||
self._builtins = dict(
|
||||
build_builtin_guides(width, height, left=left, top=top)
|
||||
)
|
||||
self._values: dict[str, float] = {}
|
||||
if values:
|
||||
for name, value in values.items():
|
||||
self.bind(name, value)
|
||||
|
||||
@property
|
||||
def values(self) -> Mapping[str, float]:
|
||||
"""Return a detached, read-only view of explicitly bound guides."""
|
||||
|
||||
return MappingProxyType(dict(self._values))
|
||||
|
||||
@property
|
||||
def builtins(self) -> Mapping[str, float]:
|
||||
"""Return the resolved built-in guide values."""
|
||||
|
||||
return MappingProxyType(dict(self._builtins))
|
||||
|
||||
def bind(self, name: str, value: float) -> float:
|
||||
"""Bind one evaluated adjustment or guide and return its numeric value."""
|
||||
|
||||
if not name or name.isspace():
|
||||
raise FormulaEvaluationError("Guide name must not be empty")
|
||||
number = _finite_number(value, f"guide {name!r}")
|
||||
self._values[name] = number
|
||||
return number
|
||||
|
||||
def resolve(self, token: str) -> float:
|
||||
"""Resolve a literal, bound guide, or DrawingML built-in guide token."""
|
||||
|
||||
token = token.strip()
|
||||
if not token:
|
||||
raise UnknownGuideError("Guide token must not be empty")
|
||||
try:
|
||||
return _finite_number(float(token), f"literal {token!r}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if token in self._values:
|
||||
return self._values[token]
|
||||
if token in self._builtins:
|
||||
return self._builtins[token]
|
||||
|
||||
dynamic = self._resolve_dynamic_builtin(token)
|
||||
if dynamic is not None:
|
||||
self._builtins[token] = dynamic
|
||||
return dynamic
|
||||
raise UnknownGuideError(f"Unknown DrawingML guide token: {token!r}")
|
||||
|
||||
def evaluate(self, formula: str) -> float:
|
||||
"""Evaluate one complete DrawingML geometry formula."""
|
||||
|
||||
parts = formula.split()
|
||||
if not parts:
|
||||
raise FormulaSyntaxError("DrawingML formula must not be empty")
|
||||
operator = parts[0]
|
||||
expected = _OPERATOR_ARITY.get(operator)
|
||||
if expected is None:
|
||||
raise FormulaSyntaxError(
|
||||
f"Unsupported DrawingML formula operator: {operator!r}"
|
||||
)
|
||||
if len(parts) < expected + 1:
|
||||
raise FormulaSyntaxError(
|
||||
f"Operator {operator!r} expects {expected} operands, "
|
||||
f"received {len(parts) - 1}: {formula!r}"
|
||||
)
|
||||
trailing = parts[expected + 1 :]
|
||||
if trailing and not (
|
||||
operator == "+-" and all(token == "0" for token in trailing)
|
||||
):
|
||||
raise FormulaSyntaxError(
|
||||
f"Operator {operator!r} expects {expected} operands, "
|
||||
f"received {len(parts) - 1}: {formula!r}"
|
||||
)
|
||||
# POI 5.4.1 carries three circular-arrow formulas with one inert
|
||||
# trailing zero ("+- xH 0 dxB 0"). Apache POI ignores surplus tokens;
|
||||
# accept only this harmless form while keeping all other arity errors.
|
||||
operands = tuple(self.resolve(token) for token in parts[1 : expected + 1])
|
||||
try:
|
||||
result = _apply_operator(operator, operands)
|
||||
except (OverflowError, ValueError) as exc:
|
||||
raise FormulaEvaluationError(
|
||||
f"Cannot evaluate DrawingML formula {formula!r}: {exc}"
|
||||
) from exc
|
||||
return _finite_number(result, f"result of {formula!r}")
|
||||
|
||||
def evaluate_value(self, value: str | int | float) -> float:
|
||||
"""Evaluate a formula string, guide token, or numeric adjustment value."""
|
||||
|
||||
if isinstance(value, str):
|
||||
parts = value.split()
|
||||
if not parts:
|
||||
raise FormulaSyntaxError("DrawingML value must not be empty")
|
||||
if parts[0] in SUPPORTED_OPERATORS:
|
||||
return self.evaluate(value)
|
||||
if len(parts) == 1:
|
||||
return self.resolve(parts[0])
|
||||
raise FormulaSyntaxError(f"Invalid DrawingML value: {value!r}")
|
||||
return _finite_number(value, "guide value")
|
||||
|
||||
def _resolve_dynamic_builtin(self, token: str) -> float | None:
|
||||
dimension_match = _DIMENSION_GUIDE_RE.fullmatch(token)
|
||||
if dimension_match:
|
||||
family, divisor_text = dimension_match.groups()
|
||||
divisor = int(divisor_text)
|
||||
base_name = {"wd": "w", "hd": "h", "ssd": "ss"}[family]
|
||||
return self._builtins[base_name] / divisor
|
||||
|
||||
angle_match = _ANGLE_GUIDE_RE.fullmatch(token)
|
||||
if angle_match:
|
||||
numerator_text, divisor_text = angle_match.groups()
|
||||
numerator = int(numerator_text or "1")
|
||||
divisor = int(divisor_text)
|
||||
return FULL_CIRCLE * numerator / divisor
|
||||
return None
|
||||
|
||||
|
||||
def evaluate_formula(
|
||||
formula: str,
|
||||
*,
|
||||
width: float,
|
||||
height: float,
|
||||
left: float = 0.0,
|
||||
top: float = 0.0,
|
||||
values: Mapping[str, float] | None = None,
|
||||
) -> float:
|
||||
"""Evaluate one formula without manually constructing an evaluator."""
|
||||
|
||||
evaluator = FormulaEvaluator(
|
||||
width,
|
||||
height,
|
||||
left=left,
|
||||
top=top,
|
||||
values=values,
|
||||
)
|
||||
return evaluator.evaluate(formula)
|
||||
|
||||
|
||||
def _apply_operator(operator: str, values: tuple[float, ...]) -> float:
|
||||
if operator == "val":
|
||||
return values[0]
|
||||
if operator == "*/":
|
||||
return 0.0 if values[2] == 0 else values[0] * values[1] / values[2]
|
||||
if operator == "+-":
|
||||
return values[0] + values[1] - values[2]
|
||||
if operator == "+/":
|
||||
return 0.0 if values[2] == 0 else (values[0] + values[1]) / values[2]
|
||||
if operator == "?:":
|
||||
return values[1] if values[0] > 0 else values[2]
|
||||
if operator == "abs":
|
||||
return abs(values[0])
|
||||
if operator == "at2":
|
||||
return math.degrees(math.atan2(values[1], values[0])) * OOXML_DEGREE
|
||||
if operator == "cat2":
|
||||
return values[0] * math.cos(math.atan2(values[2], values[1]))
|
||||
if operator == "cos":
|
||||
return values[0] * math.cos(math.radians(values[1] / OOXML_DEGREE))
|
||||
if operator == "max":
|
||||
return max(values[0], values[1])
|
||||
if operator == "min":
|
||||
return min(values[0], values[1])
|
||||
if operator == "mod":
|
||||
return math.sqrt(sum(value * value for value in values))
|
||||
if operator == "pin":
|
||||
return max(values[0], min(values[1], values[2]))
|
||||
if operator == "sat2":
|
||||
return values[0] * math.sin(math.atan2(values[2], values[1]))
|
||||
if operator == "sin":
|
||||
return values[0] * math.sin(math.radians(values[1] / OOXML_DEGREE))
|
||||
if operator == "sqrt":
|
||||
return math.sqrt(values[0])
|
||||
if operator == "tan":
|
||||
return values[0] * math.tan(math.radians(values[1] / OOXML_DEGREE))
|
||||
raise FormulaSyntaxError(f"Unsupported DrawingML formula operator: {operator!r}")
|
||||
|
||||
|
||||
def _finite_number(value: float, label: str) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FormulaEvaluationError(f"{label} must be numeric") from exc
|
||||
if not math.isfinite(number):
|
||||
raise FormulaEvaluationError(f"{label} must be finite")
|
||||
return number
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Preset Shape Data Loader
|
||||
|
||||
Load and validate the bundled DrawingML preset geometry catalog.
|
||||
|
||||
Usage:
|
||||
Import load_preset_shape_definitions from pptx_shapes.loader.
|
||||
|
||||
Examples:
|
||||
definitions = load_preset_shape_definitions()
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .errors import PresetShapeDataError
|
||||
from .models import (
|
||||
AdjustHandleDefinition,
|
||||
ConnectionSiteDefinition,
|
||||
GuideDefinition,
|
||||
PathCommandDefinition,
|
||||
PointExpression,
|
||||
PresetShapeDefinition,
|
||||
ShapePathDefinition,
|
||||
TextRectangleDefinition,
|
||||
)
|
||||
|
||||
|
||||
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
EXPECTED_SHAPE_COUNT = 187
|
||||
BUNDLED_DEFINITIONS_SHA256 = (
|
||||
"4a762444d8d85876881c02a5b1dedf6f73006fcd8acb7b4e393435615b37c780"
|
||||
)
|
||||
BUNDLED_SHAPE_TYPES_SHA256 = (
|
||||
"f2c3bdcda8569b358ce3196cfeb183849e33bfc7955fac961dc85fceb6b3b587"
|
||||
)
|
||||
|
||||
_PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
BUNDLED_DEFINITIONS_PATH = _PACKAGE_DIR / "data" / "presetShapeDefinitions.xml"
|
||||
BUNDLED_SHAPE_TYPES_PATH = _PACKAGE_DIR / "data" / "shape_type_values.txt"
|
||||
_EXPECTED_ROOT_TAG = "presetShapeDefinitons"
|
||||
_PATH_COMMAND_ARITY = {
|
||||
"moveTo": 1,
|
||||
"lnTo": 1,
|
||||
"quadBezTo": 2,
|
||||
"cubicBezTo": 3,
|
||||
"arcTo": 0,
|
||||
"close": 0,
|
||||
}
|
||||
|
||||
|
||||
def load_shape_type_values(path: Path | None = None) -> tuple[str, ...]:
|
||||
"""Load the independent Open XML ``ShapeTypeValues`` coverage list."""
|
||||
|
||||
source = path or BUNDLED_SHAPE_TYPES_PATH
|
||||
try:
|
||||
raw = source.read_bytes()
|
||||
names = tuple(
|
||||
line.strip()
|
||||
for line in raw.decode("utf-8").splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
)
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise PresetShapeDataError(
|
||||
f"Cannot read preset shape type catalog {source}: {exc}"
|
||||
) from exc
|
||||
if path is None:
|
||||
actual = hashlib.sha256(_normalized_lf_bytes(raw)).hexdigest()
|
||||
if actual != BUNDLED_SHAPE_TYPES_SHA256:
|
||||
raise PresetShapeDataError(
|
||||
f"ShapeTypeValues checksum mismatch for {source}: "
|
||||
f"expected {BUNDLED_SHAPE_TYPES_SHA256}, found {actual}"
|
||||
)
|
||||
if len(names) != len(set(names)):
|
||||
raise PresetShapeDataError(f"Duplicate names in shape type catalog: {source}")
|
||||
if len(names) != EXPECTED_SHAPE_COUNT:
|
||||
raise PresetShapeDataError(
|
||||
f"Expected {EXPECTED_SHAPE_COUNT} ShapeTypeValues, found {len(names)}"
|
||||
)
|
||||
return names
|
||||
|
||||
|
||||
def load_preset_shape_definitions(
|
||||
path: Path | None = None,
|
||||
*,
|
||||
expected_sha256: str | None = None,
|
||||
expected_names: tuple[str, ...] | None = None,
|
||||
) -> tuple[PresetShapeDefinition, ...]:
|
||||
"""Load one preset XML catalog and enforce uniqueness and coverage.
|
||||
|
||||
The bundled catalog is hash-locked automatically. External catalogs are
|
||||
validated structurally and can opt into a caller-provided hash.
|
||||
"""
|
||||
|
||||
source = path or BUNDLED_DEFINITIONS_PATH
|
||||
locked_hash = (
|
||||
BUNDLED_DEFINITIONS_SHA256
|
||||
if path is None and expected_sha256 is None
|
||||
else expected_sha256
|
||||
)
|
||||
raw = _read_verified_bytes(source, locked_hash)
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
except ET.ParseError as exc:
|
||||
raise PresetShapeDataError(f"Invalid preset geometry XML {source}: {exc}") from exc
|
||||
if _local_name(root.tag) != _EXPECTED_ROOT_TAG:
|
||||
raise PresetShapeDataError(
|
||||
f"Unexpected preset geometry root {_local_name(root.tag)!r} in {source}"
|
||||
)
|
||||
|
||||
definitions = tuple(_parse_shape(element) for element in root)
|
||||
names = tuple(definition.name for definition in definitions)
|
||||
if len(names) != len(set(names)):
|
||||
raise PresetShapeDataError(f"Duplicate preset geometry names in {source}")
|
||||
|
||||
catalog_names = expected_names
|
||||
if catalog_names is None and path is None:
|
||||
catalog_names = load_shape_type_values()
|
||||
if catalog_names is not None:
|
||||
missing = sorted(set(catalog_names) - set(names))
|
||||
extra = sorted(set(names) - set(catalog_names))
|
||||
if missing or extra:
|
||||
raise PresetShapeDataError(
|
||||
"Preset geometry coverage differs from ShapeTypeValues: "
|
||||
f"missing={missing}, extra={extra}"
|
||||
)
|
||||
return definitions
|
||||
|
||||
|
||||
def _read_verified_bytes(path: Path, expected_sha256: str | None) -> bytes:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise PresetShapeDataError(f"Cannot read preset geometry data {path}: {exc}") from exc
|
||||
if expected_sha256:
|
||||
actual = hashlib.sha256(_normalized_lf_bytes(raw)).hexdigest()
|
||||
if actual != expected_sha256:
|
||||
raise PresetShapeDataError(
|
||||
f"Preset geometry checksum mismatch for {path}: "
|
||||
f"expected {expected_sha256}, found {actual}"
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _normalized_lf_bytes(raw: bytes) -> bytes:
|
||||
"""Normalize checkout line endings before verifying text-resource hashes."""
|
||||
|
||||
return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
||||
|
||||
|
||||
def _parse_shape(element: ET.Element) -> PresetShapeDefinition:
|
||||
name = _local_name(element.tag)
|
||||
if not name:
|
||||
raise PresetShapeDataError("Preset shape name must not be empty")
|
||||
adjustments = _parse_guides(_find_child(element, "avLst"))
|
||||
guides = _parse_guides(_find_child(element, "gdLst"))
|
||||
handles = _parse_handles(_find_child(element, "ahLst"))
|
||||
connections = _parse_connections(_find_child(element, "cxnLst"))
|
||||
text_rectangle = _parse_text_rectangle(_find_child(element, "rect"))
|
||||
paths = _parse_paths(_find_child(element, "pathLst"), name)
|
||||
return PresetShapeDefinition(
|
||||
name=name,
|
||||
adjustments=adjustments,
|
||||
guides=guides,
|
||||
handles=handles,
|
||||
connections=connections,
|
||||
text_rectangle=text_rectangle,
|
||||
paths=paths,
|
||||
)
|
||||
|
||||
|
||||
def _parse_guides(container: ET.Element | None) -> tuple[GuideDefinition, ...]:
|
||||
if container is None:
|
||||
return ()
|
||||
guides = []
|
||||
for element in container:
|
||||
if _local_name(element.tag) != "gd":
|
||||
raise PresetShapeDataError(
|
||||
f"Unexpected {_local_name(element.tag)!r} in guide list"
|
||||
)
|
||||
name = _required_attribute(element, "name")
|
||||
formula = _required_attribute(element, "fmla")
|
||||
guides.append(GuideDefinition(name=name, formula=formula))
|
||||
# Some normative preset definitions intentionally rebind an intermediate
|
||||
# name later in the ordered guide list. Preserve that sequential behavior.
|
||||
return tuple(guides)
|
||||
|
||||
|
||||
def _parse_handles(
|
||||
container: ET.Element | None,
|
||||
) -> tuple[AdjustHandleDefinition, ...]:
|
||||
if container is None:
|
||||
return ()
|
||||
handles = []
|
||||
for element in container:
|
||||
kind = _local_name(element.tag)
|
||||
if kind not in {"ahXY", "ahPolar"}:
|
||||
raise PresetShapeDataError(f"Unexpected adjustment handle: {kind!r}")
|
||||
position = _parse_position(element)
|
||||
handles.append(
|
||||
AdjustHandleDefinition(
|
||||
kind="xy" if kind == "ahXY" else "polar",
|
||||
position=position,
|
||||
x_reference=element.attrib.get("gdRefX"),
|
||||
minimum_x=element.attrib.get("minX"),
|
||||
maximum_x=element.attrib.get("maxX"),
|
||||
y_reference=element.attrib.get("gdRefY"),
|
||||
minimum_y=element.attrib.get("minY"),
|
||||
maximum_y=element.attrib.get("maxY"),
|
||||
angle_reference=element.attrib.get("gdRefAng"),
|
||||
minimum_angle=element.attrib.get("minAng"),
|
||||
maximum_angle=element.attrib.get("maxAng"),
|
||||
radius_reference=element.attrib.get("gdRefR"),
|
||||
minimum_radius=element.attrib.get("minR"),
|
||||
maximum_radius=element.attrib.get("maxR"),
|
||||
)
|
||||
)
|
||||
return tuple(handles)
|
||||
|
||||
|
||||
def _parse_connections(
|
||||
container: ET.Element | None,
|
||||
) -> tuple[ConnectionSiteDefinition, ...]:
|
||||
if container is None:
|
||||
return ()
|
||||
connections = []
|
||||
for element in container:
|
||||
if _local_name(element.tag) != "cxn":
|
||||
raise PresetShapeDataError(
|
||||
f"Unexpected connection-site element: {_local_name(element.tag)!r}"
|
||||
)
|
||||
connections.append(
|
||||
ConnectionSiteDefinition(
|
||||
angle=_required_attribute(element, "ang"),
|
||||
position=_parse_position(element),
|
||||
)
|
||||
)
|
||||
return tuple(connections)
|
||||
|
||||
|
||||
def _parse_text_rectangle(
|
||||
element: ET.Element | None,
|
||||
) -> TextRectangleDefinition | None:
|
||||
if element is None:
|
||||
return None
|
||||
return TextRectangleDefinition(
|
||||
left=_required_attribute(element, "l"),
|
||||
top=_required_attribute(element, "t"),
|
||||
right=_required_attribute(element, "r"),
|
||||
bottom=_required_attribute(element, "b"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_paths(
|
||||
container: ET.Element | None,
|
||||
shape_name: str,
|
||||
) -> tuple[ShapePathDefinition, ...]:
|
||||
if container is None:
|
||||
raise PresetShapeDataError(f"Preset {shape_name!r} has no path list")
|
||||
paths = []
|
||||
for element in container:
|
||||
if _local_name(element.tag) != "path":
|
||||
raise PresetShapeDataError(
|
||||
f"Unexpected path-list element: {_local_name(element.tag)!r}"
|
||||
)
|
||||
paths.append(
|
||||
ShapePathDefinition(
|
||||
coordinate_width=element.attrib.get("w"),
|
||||
coordinate_height=element.attrib.get("h"),
|
||||
fill=element.attrib.get("fill", "norm"),
|
||||
stroke=_parse_boolean(element.attrib.get("stroke"), default=True),
|
||||
extrusion_ok=_parse_boolean(
|
||||
element.attrib.get("extrusionOk"),
|
||||
default=True,
|
||||
),
|
||||
commands=tuple(_parse_path_command(command) for command in element),
|
||||
)
|
||||
)
|
||||
if not paths:
|
||||
raise PresetShapeDataError(f"Preset {shape_name!r} has an empty path list")
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
def _parse_path_command(element: ET.Element) -> PathCommandDefinition:
|
||||
name = _local_name(element.tag)
|
||||
expected_points = _PATH_COMMAND_ARITY.get(name)
|
||||
if expected_points is None:
|
||||
raise PresetShapeDataError(f"Unsupported preset path command: {name!r}")
|
||||
if name == "arcTo":
|
||||
parameters = tuple(
|
||||
_required_attribute(element, attribute)
|
||||
for attribute in ("wR", "hR", "stAng", "swAng")
|
||||
)
|
||||
return PathCommandDefinition(name=name, parameters=parameters)
|
||||
points = tuple(
|
||||
child for child in element if _local_name(child.tag) == "pt"
|
||||
)
|
||||
if len(points) != expected_points:
|
||||
raise PresetShapeDataError(
|
||||
f"Path command {name!r} expects {expected_points} points, "
|
||||
f"found {len(points)}"
|
||||
)
|
||||
parameters = tuple(
|
||||
coordinate
|
||||
for point in points
|
||||
for coordinate in (
|
||||
_required_attribute(point, "x"),
|
||||
_required_attribute(point, "y"),
|
||||
)
|
||||
)
|
||||
return PathCommandDefinition(name=name, parameters=parameters)
|
||||
|
||||
|
||||
def _parse_position(parent: ET.Element) -> PointExpression:
|
||||
positions = [
|
||||
child for child in parent if _local_name(child.tag) == "pos"
|
||||
]
|
||||
if len(positions) != 1:
|
||||
raise PresetShapeDataError(
|
||||
f"{_local_name(parent.tag)!r} must contain exactly one position"
|
||||
)
|
||||
return PointExpression(
|
||||
x=_required_attribute(positions[0], "x"),
|
||||
y=_required_attribute(positions[0], "y"),
|
||||
)
|
||||
|
||||
|
||||
def _find_child(parent: ET.Element, local_name: str) -> ET.Element | None:
|
||||
matches = [child for child in parent if _local_name(child.tag) == local_name]
|
||||
if len(matches) > 1:
|
||||
raise PresetShapeDataError(
|
||||
f"Preset contains duplicate {local_name!r} elements"
|
||||
)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def _required_attribute(element: ET.Element, name: str) -> str:
|
||||
value = element.attrib.get(name)
|
||||
if value is None or not value.strip():
|
||||
raise PresetShapeDataError(
|
||||
f"Element {_local_name(element.tag)!r} requires attribute {name!r}"
|
||||
)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _parse_boolean(value: str | None, *, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if value in {"true", "1"}:
|
||||
return True
|
||||
if value in {"false", "0"}:
|
||||
return False
|
||||
raise PresetShapeDataError(f"Invalid DrawingML boolean value: {value!r}")
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - PPTX Shape Models
|
||||
|
||||
Immutable value objects shared by the preset catalog loader and evaluator.
|
||||
|
||||
Usage:
|
||||
Import model classes from pptx_shapes.models.
|
||||
|
||||
Examples:
|
||||
from pptx_shapes.models import GuideDefinition
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuideDefinition:
|
||||
"""One named DrawingML guide formula."""
|
||||
|
||||
name: str
|
||||
formula: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PointExpression:
|
||||
"""A point whose coordinates are numeric literals or guide names."""
|
||||
|
||||
x: str
|
||||
y: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdjustHandleDefinition:
|
||||
"""An XY or polar adjustment handle from ``a:ahLst``."""
|
||||
|
||||
kind: str
|
||||
position: PointExpression
|
||||
x_reference: str | None = None
|
||||
minimum_x: str | None = None
|
||||
maximum_x: str | None = None
|
||||
y_reference: str | None = None
|
||||
minimum_y: str | None = None
|
||||
maximum_y: str | None = None
|
||||
angle_reference: str | None = None
|
||||
minimum_angle: str | None = None
|
||||
maximum_angle: str | None = None
|
||||
radius_reference: str | None = None
|
||||
minimum_radius: str | None = None
|
||||
maximum_radius: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConnectionSiteDefinition:
|
||||
"""One preset connection site before guide evaluation."""
|
||||
|
||||
angle: str
|
||||
position: PointExpression
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextRectangleDefinition:
|
||||
"""The preset's internal text rectangle expressions."""
|
||||
|
||||
left: str
|
||||
top: str
|
||||
right: str
|
||||
bottom: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathCommandDefinition:
|
||||
"""A DrawingML path command with parameters in document order.
|
||||
|
||||
Parameter order is ``x,y`` for move/line, point order for Bezier commands,
|
||||
and ``wR,hR,stAng,swAng`` for arcs. ``close`` has no parameters.
|
||||
"""
|
||||
|
||||
name: str
|
||||
parameters: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShapePathDefinition:
|
||||
"""One path in a preset geometry definition."""
|
||||
|
||||
coordinate_width: str | None
|
||||
coordinate_height: str | None
|
||||
fill: str
|
||||
stroke: bool
|
||||
extrusion_ok: bool
|
||||
commands: tuple[PathCommandDefinition, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresetShapeDefinition:
|
||||
"""Complete immutable source definition for one preset shape."""
|
||||
|
||||
name: str
|
||||
adjustments: tuple[GuideDefinition, ...]
|
||||
guides: tuple[GuideDefinition, ...]
|
||||
handles: tuple[AdjustHandleDefinition, ...]
|
||||
connections: tuple[ConnectionSiteDefinition, ...]
|
||||
text_rectangle: TextRectangleDefinition | None
|
||||
paths: tuple[ShapePathDefinition, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluatedPoint:
|
||||
"""A point in the evaluated shape-local coordinate system."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluatedAdjustHandle:
|
||||
"""An adjustment handle with resolved position and constraint bounds."""
|
||||
|
||||
kind: str
|
||||
position: EvaluatedPoint
|
||||
x_reference: str | None = None
|
||||
minimum_x: float | None = None
|
||||
maximum_x: float | None = None
|
||||
y_reference: str | None = None
|
||||
minimum_y: float | None = None
|
||||
maximum_y: float | None = None
|
||||
angle_reference: str | None = None
|
||||
minimum_angle: float | None = None
|
||||
maximum_angle: float | None = None
|
||||
radius_reference: str | None = None
|
||||
minimum_radius: float | None = None
|
||||
maximum_radius: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluatedConnectionSite:
|
||||
"""A connection site with resolved angle and position."""
|
||||
|
||||
angle: float
|
||||
position: EvaluatedPoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluatedTextRectangle:
|
||||
"""A resolved internal text rectangle."""
|
||||
|
||||
left: float
|
||||
top: float
|
||||
right: float
|
||||
bottom: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluatedPathCommand:
|
||||
"""A path command whose parameters have all been resolved to numbers."""
|
||||
|
||||
name: str
|
||||
parameters: tuple[float, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluatedShapePath:
|
||||
"""One resolved path and its DrawingML paint behavior."""
|
||||
|
||||
coordinate_width: float
|
||||
coordinate_height: float
|
||||
fill: str
|
||||
stroke: bool
|
||||
extrusion_ok: bool
|
||||
commands: tuple[EvaluatedPathCommand, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluatedPresetGeometry:
|
||||
"""All evaluated semantic geometry for one preset shape instance."""
|
||||
|
||||
name: str
|
||||
width: float
|
||||
height: float
|
||||
left: float
|
||||
top: float
|
||||
adjustments: Mapping[str, float]
|
||||
guides: Mapping[str, float]
|
||||
handles: tuple[EvaluatedAdjustHandle, ...]
|
||||
connections: tuple[EvaluatedConnectionSite, ...]
|
||||
text_rectangle: EvaluatedTextRectangle | None
|
||||
paths: tuple[EvaluatedShapePath, ...]
|
||||
|
||||
|
||||
def immutable_mapping(values: Mapping[str, float]) -> Mapping[str, float]:
|
||||
"""Return a detached, read-only copy of a numeric mapping."""
|
||||
|
||||
return MappingProxyType(dict(values))
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Preset Shape Registry
|
||||
|
||||
Expose the complete preset catalog and evaluate shape geometry instances.
|
||||
|
||||
Usage:
|
||||
Import get_preset_registry from pptx_shapes.registry.
|
||||
|
||||
Examples:
|
||||
geometry = get_preset_registry().evaluate("rightArrow", 320, 160)
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Mapping
|
||||
|
||||
from .errors import (
|
||||
FormulaEvaluationError,
|
||||
PresetShapeDataError,
|
||||
UnknownPresetShapeError,
|
||||
)
|
||||
from .formula import FormulaEvaluator
|
||||
from .loader import load_preset_shape_definitions, load_shape_type_values
|
||||
from .models import (
|
||||
AdjustHandleDefinition,
|
||||
EvaluatedAdjustHandle,
|
||||
EvaluatedConnectionSite,
|
||||
EvaluatedPathCommand,
|
||||
EvaluatedPoint,
|
||||
EvaluatedPresetGeometry,
|
||||
EvaluatedShapePath,
|
||||
EvaluatedTextRectangle,
|
||||
PointExpression,
|
||||
PresetShapeDefinition,
|
||||
immutable_mapping,
|
||||
)
|
||||
|
||||
|
||||
AdjustmentValue = str | int | float
|
||||
CONNECTOR_PRESET_TYPES = frozenset(
|
||||
{
|
||||
"line",
|
||||
"lineInv",
|
||||
"straightConnector1",
|
||||
"bentConnector2",
|
||||
"bentConnector3",
|
||||
"bentConnector4",
|
||||
"bentConnector5",
|
||||
"curvedConnector2",
|
||||
"curvedConnector3",
|
||||
"curvedConnector4",
|
||||
"curvedConnector5",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PresetShapeRegistry:
|
||||
"""Read-only registry for all standard DrawingML preset geometries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
definitions: tuple[PresetShapeDefinition, ...],
|
||||
expected_names: tuple[str, ...],
|
||||
) -> None:
|
||||
self._definitions = {
|
||||
definition.name: definition for definition in definitions
|
||||
}
|
||||
self._names = tuple(expected_names)
|
||||
if len(self._definitions) != len(definitions):
|
||||
raise PresetShapeDataError("Preset registry contains duplicate names")
|
||||
missing = sorted(set(expected_names) - set(self._definitions))
|
||||
extra = sorted(set(self._definitions) - set(expected_names))
|
||||
if missing or extra:
|
||||
raise PresetShapeDataError(
|
||||
"Preset registry differs from ShapeTypeValues: "
|
||||
f"missing={missing}, extra={extra}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def bundled(cls) -> PresetShapeRegistry:
|
||||
"""Load the hash-locked catalog shipped with PPT Master."""
|
||||
|
||||
names = load_shape_type_values()
|
||||
definitions = load_preset_shape_definitions(expected_names=names)
|
||||
return cls(definitions, names)
|
||||
|
||||
@property
|
||||
def names(self) -> tuple[str, ...]:
|
||||
"""Return all names in official ``ShapeTypeValues`` order."""
|
||||
|
||||
return self._names
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._definitions)
|
||||
|
||||
def __contains__(self, name: object) -> bool:
|
||||
return isinstance(name, str) and name in self._definitions
|
||||
|
||||
def contains(self, name: str) -> bool:
|
||||
"""Return whether ``name`` is a standard preset in this catalog."""
|
||||
|
||||
return name in self._definitions
|
||||
|
||||
def get(self, name: str) -> PresetShapeDefinition:
|
||||
"""Return one immutable source definition or raise a precise error."""
|
||||
|
||||
try:
|
||||
return self._definitions[name]
|
||||
except KeyError as exc:
|
||||
raise UnknownPresetShapeError(
|
||||
f"Unknown DrawingML preset shape: {name!r}"
|
||||
) from exc
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
name: str,
|
||||
width: float,
|
||||
height: float,
|
||||
*,
|
||||
adjustments: Mapping[str, AdjustmentValue] | None = None,
|
||||
) -> EvaluatedPresetGeometry:
|
||||
"""Evaluate every guide, handle, connection, text rect, and path."""
|
||||
|
||||
definition = self.get(name)
|
||||
evaluator = FormulaEvaluator(width, height)
|
||||
supplied = dict(adjustments or {})
|
||||
adjustment_names = {guide.name for guide in definition.adjustments}
|
||||
unknown = sorted(set(supplied) - adjustment_names)
|
||||
if unknown:
|
||||
raise FormulaEvaluationError(
|
||||
f"Preset {name!r} has no adjustments named {unknown}"
|
||||
)
|
||||
|
||||
evaluated_adjustments: dict[str, float] = {}
|
||||
for guide in definition.adjustments:
|
||||
source = supplied.get(guide.name, guide.formula)
|
||||
value = evaluator.evaluate_value(source)
|
||||
evaluated_adjustments[guide.name] = evaluator.bind(guide.name, value)
|
||||
|
||||
evaluated_guides: dict[str, float] = {}
|
||||
for guide in definition.guides:
|
||||
value = evaluator.evaluate(guide.formula)
|
||||
evaluated_guides[guide.name] = evaluator.bind(guide.name, value)
|
||||
|
||||
return EvaluatedPresetGeometry(
|
||||
name=name,
|
||||
width=evaluator.resolve("w"),
|
||||
height=evaluator.resolve("h"),
|
||||
left=evaluator.resolve("l"),
|
||||
top=evaluator.resolve("t"),
|
||||
adjustments=immutable_mapping(evaluated_adjustments),
|
||||
guides=immutable_mapping(evaluated_guides),
|
||||
handles=tuple(
|
||||
_evaluate_handle(handle, evaluator)
|
||||
for handle in definition.handles
|
||||
),
|
||||
connections=tuple(
|
||||
EvaluatedConnectionSite(
|
||||
angle=evaluator.evaluate_value(connection.angle),
|
||||
position=_evaluate_point(connection.position, evaluator),
|
||||
)
|
||||
for connection in definition.connections
|
||||
),
|
||||
text_rectangle=_evaluate_text_rectangle(definition, evaluator),
|
||||
paths=tuple(
|
||||
EvaluatedShapePath(
|
||||
coordinate_width=_path_extent(
|
||||
path.coordinate_width,
|
||||
evaluator.resolve("w"),
|
||||
evaluator,
|
||||
),
|
||||
coordinate_height=_path_extent(
|
||||
path.coordinate_height,
|
||||
evaluator.resolve("h"),
|
||||
evaluator,
|
||||
),
|
||||
fill=path.fill,
|
||||
stroke=path.stroke,
|
||||
extrusion_ok=path.extrusion_ok,
|
||||
commands=tuple(
|
||||
EvaluatedPathCommand(
|
||||
name=command.name,
|
||||
parameters=tuple(
|
||||
evaluator.evaluate_value(parameter)
|
||||
for parameter in command.parameters
|
||||
),
|
||||
)
|
||||
for command in path.commands
|
||||
),
|
||||
)
|
||||
for path in definition.paths
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_preset_registry() -> PresetShapeRegistry:
|
||||
"""Return the process-wide, lazily loaded bundled registry."""
|
||||
|
||||
return PresetShapeRegistry.bundled()
|
||||
|
||||
|
||||
def _evaluate_point(
|
||||
point: PointExpression,
|
||||
evaluator: FormulaEvaluator,
|
||||
) -> EvaluatedPoint:
|
||||
return EvaluatedPoint(
|
||||
x=evaluator.evaluate_value(point.x),
|
||||
y=evaluator.evaluate_value(point.y),
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_handle(
|
||||
handle: AdjustHandleDefinition,
|
||||
evaluator: FormulaEvaluator,
|
||||
) -> EvaluatedAdjustHandle:
|
||||
return EvaluatedAdjustHandle(
|
||||
kind=handle.kind,
|
||||
position=_evaluate_point(handle.position, evaluator),
|
||||
x_reference=handle.x_reference,
|
||||
minimum_x=_optional_value(handle.minimum_x, evaluator),
|
||||
maximum_x=_optional_value(handle.maximum_x, evaluator),
|
||||
y_reference=handle.y_reference,
|
||||
minimum_y=_optional_value(handle.minimum_y, evaluator),
|
||||
maximum_y=_optional_value(handle.maximum_y, evaluator),
|
||||
angle_reference=handle.angle_reference,
|
||||
minimum_angle=_optional_value(handle.minimum_angle, evaluator),
|
||||
maximum_angle=_optional_value(handle.maximum_angle, evaluator),
|
||||
radius_reference=handle.radius_reference,
|
||||
minimum_radius=_optional_value(handle.minimum_radius, evaluator),
|
||||
maximum_radius=_optional_value(handle.maximum_radius, evaluator),
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_text_rectangle(
|
||||
definition: PresetShapeDefinition,
|
||||
evaluator: FormulaEvaluator,
|
||||
) -> EvaluatedTextRectangle | None:
|
||||
rectangle = definition.text_rectangle
|
||||
if rectangle is None:
|
||||
return None
|
||||
return EvaluatedTextRectangle(
|
||||
left=evaluator.evaluate_value(rectangle.left),
|
||||
top=evaluator.evaluate_value(rectangle.top),
|
||||
right=evaluator.evaluate_value(rectangle.right),
|
||||
bottom=evaluator.evaluate_value(rectangle.bottom),
|
||||
)
|
||||
|
||||
|
||||
def _optional_value(
|
||||
value: str | None,
|
||||
evaluator: FormulaEvaluator,
|
||||
) -> float | None:
|
||||
return None if value is None else evaluator.evaluate_value(value)
|
||||
|
||||
|
||||
def _path_extent(
|
||||
value: str | None,
|
||||
shape_extent: float,
|
||||
evaluator: FormulaEvaluator,
|
||||
) -> float:
|
||||
if value is None:
|
||||
return shape_extent
|
||||
resolved = evaluator.evaluate_value(value)
|
||||
return shape_extent if resolved == 0 else resolved
|
||||
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Native Shape Semantic Fingerprints
|
||||
|
||||
Build stable hashes for visible SVG text, generated preset previews, and
|
||||
native chart/table fallback subtrees.
|
||||
|
||||
Usage:
|
||||
Import the fingerprint helper for the relevant semantic carrier.
|
||||
|
||||
Examples:
|
||||
digest = svg_text_fingerprint(group_element)
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
_ROOT_TEXT_STYLE_ATTRS = frozenset({
|
||||
"class",
|
||||
"fill",
|
||||
"fill-opacity",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-style",
|
||||
"font-weight",
|
||||
"letter-spacing",
|
||||
"opacity",
|
||||
"style",
|
||||
"text-anchor",
|
||||
"text-decoration",
|
||||
"word-spacing",
|
||||
})
|
||||
|
||||
NATIVE_FALLBACK_SHA256_ATTR = "data-pptx-fallback-sha256"
|
||||
_NATIVE_FALLBACK_IGNORED_TAGS = frozenset({"metadata", "title", "desc"})
|
||||
_NATIVE_FALLBACK_IGNORED_ATTRS = frozenset({
|
||||
"id",
|
||||
"data-name",
|
||||
"data-ph-type",
|
||||
})
|
||||
_URL_ID_RE = re.compile(
|
||||
r"url\(\s*(?P<quote>['\"]?)#(?P<id>[^)'\"\s]+)(?P=quote)\s*\)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def svg_text_fingerprint(root: ET.Element) -> str:
|
||||
"""Hash text content, structure, positioning, and visible typography.
|
||||
|
||||
Shape-level movement is intentionally excluded: the native ``a:xfrm``
|
||||
owns that change and the original ``p:txBody`` remains valid. Text/tspan
|
||||
transforms and all their non-semantic attributes remain part of the hash.
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"root_style": sorted(
|
||||
(name, value)
|
||||
for name, value in root.attrib.items()
|
||||
if name in _ROOT_TEXT_STYLE_ATTRS
|
||||
),
|
||||
"text": [
|
||||
_element_payload(element)
|
||||
for element in root.iter()
|
||||
if _local_name(element.tag) == "text"
|
||||
],
|
||||
}
|
||||
canonical = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
def svg_preset_preview_fingerprint(root: ET.Element) -> str:
|
||||
"""Hash the complete visible preview subtree and intermediate wrappers."""
|
||||
payload = _preview_subtree(root, is_root=True, active=False)
|
||||
canonical = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
def svg_native_fallback_fingerprint(
|
||||
root: ET.Element,
|
||||
*,
|
||||
document_root: ET.Element | None = None,
|
||||
) -> str:
|
||||
"""Hash one native chart/table marker's rendering-relevant SVG subtree.
|
||||
|
||||
Native metadata, editor/runtime attributes, and stable element IDs are not
|
||||
fallback artwork. Reachable document-level fragment definitions are hashed
|
||||
when ``document_root`` is available. Transforms remain part of the digest
|
||||
because complete explicit native bounds are absolute and therefore do not
|
||||
consume marker transforms; changing one must make the replacement stale.
|
||||
"""
|
||||
id_tokens = _native_fallback_id_tokens(root)
|
||||
dependencies = _native_fallback_external_dependencies(
|
||||
root,
|
||||
document_root,
|
||||
id_tokens,
|
||||
)
|
||||
payload = _native_fallback_subtree(
|
||||
root,
|
||||
id_tokens=id_tokens,
|
||||
)
|
||||
if dependencies:
|
||||
payload = {
|
||||
"marker": payload,
|
||||
"external_dependencies": [
|
||||
{
|
||||
"token": id_tokens[element_id],
|
||||
"node": _native_fallback_subtree(
|
||||
target,
|
||||
id_tokens=id_tokens,
|
||||
force_include=True,
|
||||
),
|
||||
}
|
||||
for element_id, target in dependencies
|
||||
],
|
||||
}
|
||||
canonical = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
def svg_native_fallback_markup_fingerprint(
|
||||
markup: str,
|
||||
*,
|
||||
root_transform: str | None = None,
|
||||
external_markup: str | None = None,
|
||||
) -> str:
|
||||
"""Hash an SVG fallback fragment through the canonical marker function."""
|
||||
if external_markup:
|
||||
document_root = ET.fromstring(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
'xmlns:xlink="http://www.w3.org/1999/xlink">'
|
||||
f"<defs>{external_markup}</defs><g>{markup}</g>"
|
||||
"</svg>"
|
||||
)
|
||||
wrapper = document_root[-1]
|
||||
else:
|
||||
document_root = None
|
||||
wrapper = ET.fromstring(
|
||||
'<g xmlns="http://www.w3.org/2000/svg" '
|
||||
'xmlns:xlink="http://www.w3.org/1999/xlink">'
|
||||
f"{markup}"
|
||||
"</g>"
|
||||
)
|
||||
if root_transform:
|
||||
wrapper.set("transform", root_transform)
|
||||
return svg_native_fallback_fingerprint(
|
||||
wrapper,
|
||||
document_root=document_root,
|
||||
)
|
||||
|
||||
|
||||
def resolve_preset_preview_hash(root: ET.Element) -> str | None:
|
||||
"""Resolve and cross-check a logical preset group's fingerprint contract.
|
||||
|
||||
The hash is duplicated on the logical group and hidden native carrier so
|
||||
stripping either copy cannot disable stale-preview detection. A visible
|
||||
generated preview without either hash is invalid rather than legacy SVG.
|
||||
"""
|
||||
has_preview = any(
|
||||
element.get("data-pptx-part")
|
||||
in {"geometry-preview", "geometry-detail"}
|
||||
for element in root.iter()
|
||||
)
|
||||
carrier_hashes = {
|
||||
value
|
||||
for element in root.iter()
|
||||
if element.get("data-pptx-part") == "geometry"
|
||||
and (value := element.get("data-pptx-preview-sha256")) is not None
|
||||
}
|
||||
group_hash = root.get("data-pptx-preview-sha256")
|
||||
if not has_preview and not carrier_hashes and group_hash is None:
|
||||
return None
|
||||
if len(carrier_hashes) > 1:
|
||||
raise ValueError("Native geometry carriers have inconsistent preview hashes")
|
||||
carrier_hash = next(iter(carrier_hashes), None)
|
||||
if (
|
||||
group_hash is not None
|
||||
and carrier_hash is not None
|
||||
and group_hash != carrier_hash
|
||||
):
|
||||
raise ValueError("Logical group and native carrier preview hashes differ")
|
||||
expected = group_hash or carrier_hash
|
||||
if expected is None:
|
||||
raise ValueError("Generated preset preview is missing its fingerprint")
|
||||
return expected
|
||||
|
||||
|
||||
def _preview_subtree(
|
||||
element: ET.Element,
|
||||
*,
|
||||
is_root: bool,
|
||||
active: bool,
|
||||
) -> dict | None:
|
||||
part = element.get("data-pptx-part")
|
||||
contains_preview = part in {"geometry-preview", "geometry-detail"}
|
||||
child_active = active or contains_preview
|
||||
children = [
|
||||
payload
|
||||
for child in element
|
||||
if (
|
||||
payload := _preview_subtree(
|
||||
child,
|
||||
is_root=False,
|
||||
active=child_active,
|
||||
)
|
||||
) is not None
|
||||
]
|
||||
if is_root:
|
||||
return {"children": children}
|
||||
if not child_active and not children:
|
||||
return None
|
||||
return {
|
||||
"tag": _local_name(element.tag),
|
||||
"attrs": sorted(
|
||||
(name, value)
|
||||
for name, value in element.attrib.items()
|
||||
if name != "id"
|
||||
and name != "data-pptx-preview-sha256"
|
||||
and not name.startswith("data-pptx-runtime-")
|
||||
),
|
||||
"children": children,
|
||||
}
|
||||
|
||||
|
||||
def _native_fallback_subtree(
|
||||
element: ET.Element,
|
||||
*,
|
||||
id_tokens: dict[str, str],
|
||||
force_include: bool = False,
|
||||
) -> dict | None:
|
||||
tag = _local_name(element.tag)
|
||||
if not force_include and _native_fallback_element_hidden(element):
|
||||
return None
|
||||
|
||||
attrs = []
|
||||
for raw_name, raw_value in element.attrib.items():
|
||||
name = _local_name(raw_name)
|
||||
if name in _NATIVE_FALLBACK_IGNORED_ATTRS:
|
||||
continue
|
||||
if name.startswith("data-pptx-"):
|
||||
continue
|
||||
attrs.append((
|
||||
raw_name,
|
||||
_normalize_native_fallback_id_refs(name, raw_value, id_tokens),
|
||||
))
|
||||
|
||||
children = []
|
||||
for child in element:
|
||||
child_payload = _native_fallback_subtree(
|
||||
child,
|
||||
id_tokens=id_tokens,
|
||||
)
|
||||
if child_payload is None:
|
||||
continue
|
||||
entry = {"node": child_payload}
|
||||
if child.tail and (
|
||||
child.tail.strip() or tag in {"text", "tspan", "textPath"}
|
||||
):
|
||||
entry["tail"] = child.tail
|
||||
children.append(entry)
|
||||
|
||||
text = element.text or ""
|
||||
payload = {
|
||||
"tag": tag,
|
||||
"attrs": sorted(attrs),
|
||||
"children": children,
|
||||
}
|
||||
if text and (text.strip() or tag in {"text", "tspan", "textPath", "style"}):
|
||||
payload["text"] = text
|
||||
return payload
|
||||
|
||||
|
||||
def _native_fallback_id_tokens(
|
||||
root: ET.Element,
|
||||
) -> dict[str, str]:
|
||||
tokens: dict[str, str] = {}
|
||||
|
||||
def visit(
|
||||
element: ET.Element,
|
||||
*,
|
||||
is_root: bool,
|
||||
path: tuple[int, ...],
|
||||
) -> None:
|
||||
element_id = None if is_root else element.get("id")
|
||||
if element_id and element_id not in tokens:
|
||||
tokens[element_id] = "native-node-" + "-".join(map(str, path))
|
||||
canonical_index = 0
|
||||
for child in element:
|
||||
if _native_fallback_element_hidden(child):
|
||||
continue
|
||||
visit(
|
||||
child,
|
||||
is_root=False,
|
||||
path=(*path, canonical_index),
|
||||
)
|
||||
canonical_index += 1
|
||||
|
||||
visit(root, is_root=True, path=())
|
||||
return tokens
|
||||
|
||||
|
||||
def _native_fallback_external_dependencies(
|
||||
marker: ET.Element,
|
||||
document_root: ET.Element | None,
|
||||
id_tokens: dict[str, str],
|
||||
) -> list[tuple[str, ET.Element]]:
|
||||
"""Resolve the marker's reachable document-level fragment references."""
|
||||
if document_root is None or document_root is marker:
|
||||
return []
|
||||
|
||||
marker_nodes = set(marker.iter())
|
||||
targets: dict[str, ET.Element] = {}
|
||||
for element in document_root.iter():
|
||||
element_id = element.get("id")
|
||||
if element_id and element_id not in targets:
|
||||
targets[element_id] = element
|
||||
|
||||
dependencies: list[tuple[str, ET.Element]] = []
|
||||
|
||||
def add_reference(element_id: str) -> None:
|
||||
if not element_id or element_id in id_tokens:
|
||||
return
|
||||
target = targets.get(element_id)
|
||||
if target is None or target in marker_nodes:
|
||||
return
|
||||
id_tokens[element_id] = f"native-external-{len(dependencies) + 1}"
|
||||
dependencies.append((element_id, target))
|
||||
for nested_id in _native_fallback_fragment_references(
|
||||
target,
|
||||
force_include_root=True,
|
||||
):
|
||||
add_reference(nested_id)
|
||||
|
||||
for element_id in _native_fallback_fragment_references(marker):
|
||||
add_reference(element_id)
|
||||
return dependencies
|
||||
|
||||
|
||||
def _native_fallback_fragment_references(
|
||||
root: ET.Element,
|
||||
*,
|
||||
force_include_root: bool = False,
|
||||
) -> list[str]:
|
||||
references: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(element_id: str) -> None:
|
||||
if element_id and element_id not in seen:
|
||||
seen.add(element_id)
|
||||
references.append(element_id)
|
||||
|
||||
def visit(element: ET.Element, *, force_include: bool) -> None:
|
||||
if not force_include and _native_fallback_element_hidden(element):
|
||||
return
|
||||
for raw_name, raw_value in sorted(element.attrib.items()):
|
||||
name = _local_name(raw_name)
|
||||
if name in _NATIVE_FALLBACK_IGNORED_ATTRS:
|
||||
continue
|
||||
if name.startswith("data-pptx-"):
|
||||
continue
|
||||
for match in _URL_ID_RE.finditer(raw_value):
|
||||
add(match.group("id"))
|
||||
if name == "href" and raw_value.startswith("#"):
|
||||
add(raw_value[1:])
|
||||
for child in element:
|
||||
visit(child, force_include=False)
|
||||
|
||||
visit(root, force_include=force_include_root)
|
||||
return references
|
||||
|
||||
|
||||
def _native_fallback_element_hidden(element: ET.Element) -> bool:
|
||||
if _local_name(element.tag) in _NATIVE_FALLBACK_IGNORED_TAGS:
|
||||
return True
|
||||
# ``display:none`` suppresses the entire descendant subtree. SVG
|
||||
# ``visibility`` is different: a descendant may explicitly restore
|
||||
# ``visibility:visible``. Keep visibility-hidden content in the digest
|
||||
# conservatively so such visible descendants cannot evade stale detection.
|
||||
return _native_fallback_style_value(element, "display") == "none"
|
||||
|
||||
|
||||
def _normalize_native_fallback_id_refs(
|
||||
name: str,
|
||||
value: str,
|
||||
id_tokens: dict[str, str],
|
||||
) -> str:
|
||||
def replace_url(match: re.Match[str]) -> str:
|
||||
token = id_tokens.get(match.group("id"))
|
||||
return f"url(#{token})" if token is not None else match.group(0)
|
||||
|
||||
normalized = _URL_ID_RE.sub(replace_url, value)
|
||||
if name in {"href", "xlink:href"} and normalized.startswith("#"):
|
||||
token = id_tokens.get(normalized[1:])
|
||||
if token is not None:
|
||||
return f"#{token}"
|
||||
return normalized
|
||||
|
||||
|
||||
def _native_fallback_style_value(element: ET.Element, name: str) -> str | None:
|
||||
raw = element.get(name)
|
||||
if raw is not None:
|
||||
return raw.strip().lower()
|
||||
style = element.get("style") or ""
|
||||
for declaration in style.split(";"):
|
||||
if ":" not in declaration:
|
||||
continue
|
||||
key, value = declaration.split(":", 1)
|
||||
if key.strip().lower() == name:
|
||||
return value.strip().lower()
|
||||
return None
|
||||
|
||||
|
||||
def _element_payload(element: ET.Element) -> dict:
|
||||
return {
|
||||
"tag": _local_name(element.tag),
|
||||
"attrs": sorted(
|
||||
(name, value)
|
||||
for name, value in element.attrib.items()
|
||||
if not name.startswith("data-pptx-") and name != "id"
|
||||
),
|
||||
"text": element.text or "",
|
||||
"children": [
|
||||
{
|
||||
"node": _element_payload(child),
|
||||
"tail": child.tail or "",
|
||||
}
|
||||
for child in element
|
||||
if _local_name(child.tag) in {"text", "tspan"}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Safety checks for opaque DrawingML fragments copied between PPTX parts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
RELATIONSHIPS_NS = (
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
)
|
||||
_RELATIONSHIP_ATTRIBUTE_PREFIX = f"{{{RELATIONSHIPS_NS}}}"
|
||||
|
||||
|
||||
def has_relationship_attributes(root: ET.Element) -> bool:
|
||||
"""Return whether a subtree contains any part-local relationship QName."""
|
||||
return any(
|
||||
attribute.startswith(_RELATIONSHIP_ATTRIBUTE_PREFIX)
|
||||
for element in root.iter()
|
||||
for attribute in element.attrib
|
||||
)
|
||||
Reference in New Issue
Block a user