diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..cd0b774 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [Sodium-Hydrogen] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d763079 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Report an issue with svg2mod +description: Report an issue with svg2mod. +labels: [bug, unconfirmed] +body: + - type: markdown + attributes: + value: | + This issue form is for reporting bugs only! + + If you have a feature or enhancement request, please use the [ideas category][fr] in the github discussions. + + [fr]: https://github.com/svg2mod/svg2mod/discussions/categories/ideas + + - type: textarea + validations: + required: true + attributes: + label: The problem + description: | + Describe the issue you are experiencing here, to communicate to the + maintainers. Tell us what you were trying to do and what happened. + + Provide a clear and concise description of what the problem is. + + - type: input + validations: + required: true + attributes: + label: Version + description: | + The version of svg2mod you are using. + + If installed via pip you can use `pip show svg2mod` to get the version. + + Otherwise `git describe --tag` if you installed locally. + + - type: input + validations: + required: true + id: command_line + attributes: + label: Command line + description: > + The command that when run will reproduce the error. + + - type: textarea + validations: + required: true + attributes: + label: Debug output + description: The output of the command. Please use the `--debug` flag. + render: txt + + - type: textarea + validations: + required: true + attributes: + label: Sample file and additional information + description: | + __Please attach a sample file__ that will trigger the issue and + if you have any additional information for us, use the field below. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..127775b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Feature Request + url: https://github.com/svg2mod/svg2mod/discussions/categories/ideas + about: Please use our GitHub Discussions board for making feature requests. + - name: Questions + url: https://github.com/svg2mod/svg2mod/discussions/categories/q-a + about: The issue tracker is only for issues. Please keep all other questions and discussions to GitHub Disscussions. \ No newline at end of file diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml new file mode 100644 index 0000000..1fb3c61 --- /dev/null +++ b/.github/workflows/markdown-lint.yml @@ -0,0 +1,27 @@ +name: Documentation Linting + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16.x + - name: Installing dependencies + run: | + npm install -g markdownlint-cli2 + npm install -g cspell + - name: Lint + run: markdownlint-cli2-config ".linter/.markdownlint.yaml" "*.md" + - name: Spellcheck + run: cspell -c .linter/cspell.json "*.md" --show-suggestions + \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..73ffe7b --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,46 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python lint and test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install wheel pytest pylint pyenchant ./ + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with pylint + run: | + pylint --rcfile .linter/cleanup.rc svg2mod setup.py + - name: Test with pytest + run: | + python setup.py test + - name: Run svg tests + run: | + svg2mod -i examples/svg2mod.svg -o output.mod -x -c -P --name TEST --value VALUE -f 2 -p 1 --format legacy --units mm -d 300 --debug + svg2mod -i examples/svg2mod.svg --format legacy --force F.Cu + svg2mod -i examples/svg2mod.svg --format legacy -o output.mod + svg2mod -i examples/svg2mod.svg --format pretty --debug --force F.Cu -x + svg2mod -i examples/svg2mod.svg --debug + svg2mod -i examples/svg2mod.svg + svg2mod -l diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..9cb7e23 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,37 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install wheel setuptools + - name: Build package + run: python setup.py bdist_wheel sdist + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + diff --git a/.gitignore b/.gitignore index 24bc31d..d8113fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,18 @@ *~ .*.sw? *.pyc +build +dist +svg2mod.egg-info +.eggs +*.kicad_mod +*.mod +*.svg +__pycache__ +.pytest_cache +Pipfile +Pipfile.lock +.vscode +*.zip +*.cmd +*.sh \ No newline at end of file diff --git a/.linter/.markdownlint.yaml b/.linter/.markdownlint.yaml new file mode 100644 index 0000000..0d39f14 --- /dev/null +++ b/.linter/.markdownlint.yaml @@ -0,0 +1,4 @@ +default: true + +MD013: + line_length: 100 \ No newline at end of file diff --git a/.linter/cleanup.rc b/.linter/cleanup.rc new file mode 100644 index 0000000..e74034d --- /dev/null +++ b/.linter/cleanup.rc @@ -0,0 +1,543 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=0.0 + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=0 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=all + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member, + anomalous-backslash-in-string, + fixme, + line-too-long, + no-else-return, + no-else-break, + no-else-continue, + no-else-raise, + useless-object-inheritance, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-nested-blocks, + too-many-statements, + too-many-function-args, + pointless-string-statement, + superfluous-parens, + no-self-use, + signature-differs, + too-many-lines, + wrong-import-order, + useless-import-alias, + continue-in-finally, + duplicate-code, + pointless-statement, + pointless-string-statement, + unused-import, + unused-variable, + unused-argument, + unused-wildcard-import, + attribute-defined-outside-init, + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=no + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: en_GB (hunspell), en_ZA +# (hunspell), en_AU (hunspell), en_US (hunspell), en (aspell), en_CA +# (hunspell). +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=new + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=150 + +# Maximum number of lines in a module. +max-module-lines=1500 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/.linter/cspell.json b/.linter/cspell.json new file mode 100644 index 0000000..9158496 --- /dev/null +++ b/.linter/cspell.json @@ -0,0 +1,9 @@ +{ + "dictionaries": ["custom_dict"], + "dictionaryDefinitions": [ + { + "name": "custom_dict", + "path": "./custom_dict" + } + ] +} \ No newline at end of file diff --git a/.linter/custom_dict b/.linter/custom_dict new file mode 100644 index 0000000..662c54d --- /dev/null +++ b/.linter/custom_dict @@ -0,0 +1,68 @@ +adhes +API +attrib +bezier +cjlano +Cmap +cmts +collinear +coloredlogger +coord +copperpour +decompiles +Demibold +descr +Douglas +DPI +dwgs +elt +evenodd +Faux +fonttools +formatter +getLogger +init +inkscape +inlinable +inlined +iterable +json +keepout +kicad +kipart +levelno +otf +pc +PCBNEW +pdistance +Peucker +poly +pre +precompute +px +py +pyenchant +Ramer +rect +Regex +rx +ry +stderr +stdout +streamlitapp +svg +sys +tedit +thru +Traceback +ttf +txt +un +vect +viewport +Wikipedia +xlength +xml +xscale +ylength +yscale diff --git a/.linter/required-linting.rc b/.linter/required-linting.rc new file mode 100644 index 0000000..4ae5779 --- /dev/null +++ b/.linter/required-linting.rc @@ -0,0 +1,559 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=all + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member, + missing-function-docstring, + missing-class-docstring, + missing-module-docstring, + trailing-whitespace, + redefined-builtin, + bad-indentation, + unidiomatic-typecheck, + undefined-variable, + no-member, + dangerous-default-value, + expression-not-assigned, + invalid-length-returned, + invalid-bool-returned, + invalid-index-returned, + invalid-repr-returned, + invalid-str-returned, + invalid-bytes-returned, + invalid-hash-returned, + invalid-length-hint-returned, + invalid-format-returned, + invalid-getnewargs-returned, + invalid-getnewargs-ex-returned, + used-before-assignment, + raising-bad-type, + bad-exception-context, + bad-super-call, + not-an-iterable, + no-method-argument, + no-self-argument, + invalid-slots-object, + assigning-non-slot, + invalid-slots, + inherit-non-class, + empty-docstring, + non-ascii-name, + unexpected-line-ending-format, + syntax-error, + unrecognized-inline-option, + return-in-init, + duplicate-argument-name, + abstract-class-instantiated, + not-callable, + assignment-from-no-return, + fatal, + parse-error, + wrong-spelling-in-comment, + wrong-spelling-in-docstring, + invalid-characters-in-docstring + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=no + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: en_GB (hunspell), en_ZA +# (hunspell), en_AU (hunspell), en_US (hunspell), en (aspell), en_CA +# (hunspell). +spelling-dict=en_US + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file=.linter/custom_dict + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/LICENSE b/LICENSE index 2c4afab..ecbc059 100644 --- a/LICENSE +++ b/LICENSE @@ -1,117 +1,339 @@ -CC0 1.0 Universal - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator and -subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for the -purpose of contributing to a commons of creative, cultural and scientific -works ("Commons") that the public can reliably and without fear of later -claims of infringement build upon, modify, incorporate in other works, reuse -and redistribute as freely as possible in any form whatsoever and for any -purposes, including without limitation commercial purposes. These owners may -contribute to the Commons to promote the ideal of a free culture and the -further production of creative, cultural and scientific works, or to gain -reputation or greater distribution for their Work in part through the use and -efforts of others. - -For these and/or other purposes and motivations, and without any expectation -of additional consideration or compensation, the person associating CC0 with a -Work (the "Affirmer"), to the extent that he or she is an owner of Copyright -and Related Rights in the Work, voluntarily elects to apply CC0 to the Work -and publicly distribute the Work under its terms, with knowledge of his or her -Copyright and Related Rights in the Work and the meaning and intended legal -effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not limited -to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, communicate, - and translate a Work; - - ii. moral rights retained by the original author(s) and/or performer(s); - - iii. publicity and privacy rights pertaining to a person's image or likeness - depicted in a Work; - - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - - v. rights protecting the extraction, dissemination, use and reuse of data in - a Work; - - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation thereof, - including any amended or successor version of such directive); and - - vii. other similar, equivalent or corresponding rights throughout the world - based on applicable law or treaty, and any national implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention of, -applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and -unconditionally waives, abandons, and surrenders all of Affirmer's Copyright -and Related Rights and associated claims and causes of action, whether now -known or unknown (including existing as well as future claims and causes of -action), in the Work (i) in all territories worldwide, (ii) for the maximum -duration provided by applicable law or treaty (including future time -extensions), (iii) in any current or future medium and for any number of -copies, and (iv) for any purpose whatsoever, including without limitation -commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes -the Waiver for the benefit of each member of the public at large and to the -detriment of Affirmer's heirs and successors, fully intending that such Waiver -shall not be subject to revocation, rescission, cancellation, termination, or -any other legal or equitable action to disrupt the quiet enjoyment of the Work -by the public as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason be -judged legally invalid or ineffective under applicable law, then the Waiver -shall be preserved to the maximum extent permitted taking into account -Affirmer's express Statement of Purpose. In addition, to the extent the Waiver -is so judged Affirmer hereby grants to each affected person a royalty-free, -non transferable, non sublicensable, non exclusive, irrevocable and -unconditional license to exercise Affirmer's Copyright and Related Rights in -the Work (i) in all territories worldwide, (ii) for the maximum duration -provided by applicable law or treaty (including future time extensions), (iii) -in any current or future medium and for any number of copies, and (iv) for any -purpose whatsoever, including without limitation commercial, advertising or -promotional purposes (the "License"). The License shall be deemed effective as -of the date CC0 was applied by Affirmer to the Work. Should any part of the -License for any reason be judged legally invalid or ineffective under -applicable law, such partial invalidity or ineffectiveness shall not -invalidate the remainder of the License, and in such case Affirmer hereby -affirms that he or she will not (i) exercise any of his or her remaining -Copyright and Related Rights in the Work or (ii) assert any associated claims -and causes of action with respect to the Work, in either case contrary to -Affirmer's express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - - b. Affirmer offers the Work as-is and makes no representations or warranties - of any kind concerning the Work, express, implied, statutory or otherwise, - including without limitation warranties of title, merchantability, fitness - for a particular purpose, non infringement, or the absence of latent or - other defects, accuracy, or the present or absence of errors, whether or not - discoverable, all to the greatest extent permissible under applicable law. - - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without limitation - any person's Copyright and Related Rights in the Work. Further, Affirmer - disclaims responsibility for obtaining any necessary consents, permissions - or other rights required for any use of the Work. - - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to this - CC0 or use of the Work. - -For more information, please see - + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. \ No newline at end of file diff --git a/README.md b/README.md index d048ea5..ac05603 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,198 @@ # svg2mod -This is a small program to convert Inkscape SVG drawings to KiCad footprint module files. It uses [cjlano's python SVG parser and drawing module](https://github.com/cjlano/svg) to interpret drawings and approximate curves using straight line segments. Module files can be output in KiCad's legacy or s-expression (i.e., pretty) formats. Horizontally mirrored modules are automatically generated for use on the back of a 2-layer PCB. + +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/svg2mod/svg2mod/python-package.yml?branch=main&logo=github&style=for-the-badge)](https://github.com/svg2mod/svg2mod/actions/workflows/python-package.yml) +[![GitHub last commit](https://img.shields.io/github/last-commit/svg2mod/svg2mod?style=for-the-badge)](https://github.com/svg2mod/svg2mod/commits/main) + +[![PyPI](https://img.shields.io/pypi/v/svg2mod?color=informational&label=version&style=for-the-badge)](https://pypi.org/project/svg2mod/) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/svg2mod?style=for-the-badge)](https://pypi.org/project/svg2mod/) +[![PyPI - Downloads](https://img.shields.io/pypi/dm/svg2mod?style=for-the-badge)](https://pypi.org/project/svg2mod/) + +[![PyPI - License](https://img.shields.io/pypi/l/svg2mod?color=purple&style=for-the-badge)](https://pypi.org/project/svg2mod/) + +[![GitHub Sponsors](https://img.shields.io/github/sponsors/sodium-hydrogen?logo=github&style=for-the-badge&color=red)](https://github.com/sponsors/Sodium-Hydrogen) + +This is a program / library to convert SVG drawings to KiCad footprint module files. + +It includes a modified version of [cjlano's python SVG parser and drawing module](https://github.com/cjlano/svg) +to interpret drawings and approximate curves using straight line segments. Module files can be +output in KiCad's legacy or s-expression (i.e., pretty) formats. + +## Requirements + +* Python 3 +* [fonttools](https://pypi.org/project/fonttools/) + +## Installation + +```pip install svg2mod``` + +### Don't want to install the application? + +You can use a 3rd party web application instead: + +* [svg2mod.streamlitapp.com](https://inputblackboxoutput-streamlit-svg2mod-app-fszqih.streamlitapp.com/) +* [svg2mod.com](https://svg2mod.com/) + +## Showcase + +We'd love to see the amazing projects that use svg2mod. + +If you have a project you are proud of please post about it on our +[github discussions board](https://github.com/svg2mod/svg2mod/discussions/categories/show-and-tell) + +[![GitHub Discussions](https://img.shields.io/github/discussions/svg2mod/svg2mod?logo=github&style=for-the-badge)](https://github.com/svg2mod/svg2mod/discussions/categories/show-and-tell) + +## Example + +```svg2mod input.svg``` ## Usage -``` -usage: svg2mod.py [-h] -i FILENAME [-o FILENAME] [--name NAME] [--value VALUE] - [-f FACTOR] [-p PRECISION] [-d DPI] [--front-only] [--format FORMAT] - [--units UNITS] + +```text +usage: svg2mod [-h] [-i FILENAME] [-o FILENAME] [-c] [-P] [-v] [--debug] [-x] + [--force LAYER] [-d DPI] [-f FACTOR] [-p PRECISION] + [--format FORMAT] [--name NAME] [--units UNITS] [--value VALUE] + [-F DEFAULT_FONT] [-l] + [IN_FILENAME] Convert Inkscape SVG drawings to KiCad footprint modules. +positional arguments: + IN_FILENAME Name of the SVG file + optional arguments: -h, --help show this help message and exit -i FILENAME, --input-file FILENAME - name of the SVG file + Name of the SVG file, but specified with a flag. -o FILENAME, --output-file FILENAME - name of the module file - --name NAME, --module-name NAME - base name of the module - --value VALUE, --module-value VALUE - value of the module + Name of the module file + -c, --center Center the module to the center of the bounding box + -P, --convert-pads Convert any artwork on Cu layers to pads + -v, --verbose Print more verbose messages + --debug Print debug level messages + -x, --exclude-hidden Do not export hidden objects + --force LAYER, --force-layer LAYER + Force everything into the single provided layer + -d DPI, --dpi DPI DPI of the SVG file (int) -f FACTOR, --factor FACTOR - scale paths by this factor + Scale paths by this factor -p PRECISION, --precision PRECISION - smoothness for approximating curves with line segments - (float) - -d DPI, --dpi DPI DPI of the SVG file (int) - --front-only omit output of back module (legacy output format) - --format FORMAT output module file format (legacy|pretty) - --units UNITS output units, if output format is legacy (decimil|mm) + Smoothness for approximating curves with line + segments. Input is the approximate length for each + line segment in SVG pixels (float) + --format FORMAT Output module file format (legacy|pretty|latest). + 'latest' introduces features used in kicad >= 6 + --name NAME, --module-name NAME + Base name of the module + --units UNITS Output units, if output format is legacy (decimal|mm) + --value VALUE, --module-value VALUE + Value of the module + -F DEFAULT_FONT, --default-font DEFAULT_FONT + Default font to use if the target font in a text + element cannot be found + -l, --list-fonts List all fonts that can be found in common locations ``` ## SVG Files -svg2mod expects images saved in the uncompressed Inkscape SVG (i.e., not "plain SVG") format. - * Drawings should be to scale (1 mm in Inscape will be 1 mm in KiCad). Use the --factor option to resize the resulting module(s) up or down from there. - * Paths are supported. - * A path may have an outline and a fill. (Colors will be ignored.) - * A path may have holes, defined by interior segments within the path (see included examples). Sometimes this will render propery in KiCad, but sometimes not. - * Paths with filled areas within holes may not work at all. - * Groups may be used. However, styles applied to groups (e.g., stroke-width) are not applied to contained drawing elements. In these cases, it may be necessary to ungroup (and perhaps regroup) the elements. - * Layers must be used to indicate the mapping of drawing elements to KiCad layers. - * Layers must be named according to the rules below. - * Drawing elements will be mapped to front layers by default. Mirrored images of these elements can be automatically generated and mapped to back layers in a separate module (see --front-only option). - * Other types of elements such as rect, arc, and circle are not supported. - * Use Inkscape's "Path->Object To Path" and "Path->Stroke To Path" menu options to convert these elements into paths that will work. +svg2mod expects images saved in the uncompressed Inkscape SVG (i.e., not "plain SVG") format. This +is so it can associate inkscape layers with kicad layers + +* Drawings should be to scale (1 mm in Inkscape will be 1 mm in KiCad). Use the --factor option to +resize the resulting module(s) up or down from there. + +* Most elements are fully supported. + * A path may have an outline and a fill. (Colors will be ignored.) + * A path may have holes, defined by interior segments within the path (see included examples). + * 100% Transparent fills and strokes with be ignored. + * Text Elements are partially supported +* Groups may be used. Styles applied to groups (e.g., stroke-width) are applied to contained drawing + elements. + +* Layers or items must be named to match the target in kicad. The supported layers are listed below. + They will be ignored otherwise. + * These are pulled from `inkscape:label` but will pull from `id` if the label isn't set. + +* __If there is an issue parsing an inkscape object or stroke convert it to a path.__ + * __Use Inkscape's "Path->Object To Path" and "Path->Stroke To Path" menu options to convert these__ + __elements into paths that will work.__ ### Layers -Layers must be named (case-insensitive) according to the following rules: - -| Inkscape layer name | KiCad layer(s) | KiCad legacy | KiCad pretty | -|:-------------------:|:----------------:|:------------:|:------------:| -| Cu | F.Cu, B.Cu | Yes | Yes | -| Adhes | F.Adhes, B.Adhes | Yes | Yes | -| Paste | F.Paste, B.Paste | Yes | Yes | -| SilkS | F.SilkS, B.SilkS | Yes | Yes | -| Mask | F.Mask, B.Mask | Yes | Yes | -| Dwgs.User | Dwgs.User | Yes | -- | -| Cmts.User | Cmts.User | Yes | -- | -| Eco1.User | Eco1.User | Yes | -- | -| Eco2.User | Eco2.User | Yes | -- | -| Edge.Cuts | Edge.Cuts | Yes | Yes | -| Fab | F.Fab, B.Fab | -- | Yes | -| CrtYd | F.CrtYd, B.CrtYd | -- | Yes | - -Note: If you have a layer "Cu", all of its sub-layers will be treated as "Cu" regardless of their names. + +This supports the layers listed below. They are the same in inkscape and kicad: + +| KiCad layer(s) | KiCad legacy | KiCad pretty | +|:--------------------:|:------------:|:------------:| +| F.Cu [^1] | Yes | Yes | +| B.Cu [^1] | Yes | Yes | +| F.Adhes | Yes | Yes | +| B.Adhes | Yes | Yes | +| F.Paste | Yes | Yes | +| B.Paste | Yes | Yes | +| F.SilkS | Yes | Yes | +| B.SilkS | Yes | Yes | +| F.Mask | Yes | Yes | +| B.Mask | Yes | Yes | +| Dwgs.User | Yes | Yes | +| Cmts.User | Yes | Yes | +| Eco1.User | Yes | Yes | +| Eco2.User | Yes | Yes | +| Edge.Cuts | Yes | Yes | +| F.Fab | -- | Yes | +| B.Fab | -- | Yes | +| F.CrtYd | -- | Yes | +| B.CrtYd | -- | Yes | +| Drill.Cu [^1] [^2] | -- | Yes | +| Drill.Mech [^1] [^2] | -- | Yes | +| *.Keepout [^1] [^4] | -- | Yes [^3] | + +Note: If you have a layer `F.Cu`, all of its sub-layers will be treated as `F.Cu` regardless of their +names. + +### Layer Options + +Some layers can have options when saving to the newer 'pretty' format. + +The options are separated from the layer name by `:`. Ex `F.Cu:...` + +Some options can have arguments which are also separated from +the option key by `:`. If an option has more than one argument they +are separated by a comma. Ex: `F.Cu:Pad:1,mask`. + +If a layer has more than one option they will be separated by `;` +Ex: `F.Cu:pad;...` + +Supported Arguments: + +* Pad + + Any copper layer can have the pad specified. + The pad option can be used solo (`F.Cu:Pad`) or it can also have it's own arguments. + The arguments are: + + * Number + If it is set it will specify the number of the pad. Ex: `Pad:1` + + * Paste _(Not available for `Drill.Cu`)_ + * Mask _(Not available for `Drill.Cu`)_ + +* Allowed + + Keepout areas will prevent anything from being placed inside them. + To allow some things to be placed inside the keepout zone a comma + separated list of any of the following options can be used: + `tracks`,`vias`,`pads`,`copperpour`,`footprints` + +* Hatch + + Keepout areas have different hatching styles. This allows customization + of the appearance of hatching when converting from an svg, Ex: `F.Keepout:Hatch:edge`. + All available hatch options are `none`, `edge`, `full`. + +[^1]: These layers can have arguments when svg2mod is in pretty mode + +[^2]: Drills can only be svg circle objects. The stroke width in `Drill.Cu` is the pad size and the fill is the drill size. + +[^3]: Only works in Kicad versions >= v6 (`--format latest`). + +[^4]: The \* can be { \*, F, B, I } or any combination like FB or BI. These options are for Front, Back, and Internal. diff --git a/examples/dt-logo.mod b/examples/dt-logo.mod deleted file mode 100644 index ca01a35..0000000 --- a/examples/dt-logo.mod +++ /dev/null @@ -1,500 +0,0 @@ -PCBNEW-LibModule-V1 Wed 23 Sep 2015 12:56:11 PM -Units mm -$INDEX -DT-Logo-Front -DT-Logo-Back -$EndINDEX -# -# dt-logo.svg -# -$MODULE DT-Logo-Front -Po 0 0 0 15 00000000 00000000 ~~ -Li DT-Logo-Front -T0 0 -4.48650007222 1.524 1.524 0 0.3048 N I 21 "DT-Logo-Front" -T1 0 4.48650007222 1.524 1.524 0 0.3048 N I 21 "G***" -DP 0 0 0 0 45 0.00254 23 -Dl -1.11500002778 -1.43850007222 -Dl 1.11500002778 -1.43850007222 -Dl 1.21826499778 -1.43021840222 -Dl 1.31612738778 -1.40623191222 -Dl 1.40729941778 -1.36782838222 -Dl 1.49049330778 -1.31629559222 -Dl 1.56442127778 -1.25292132222 -Dl 1.62779554778 -1.17899335222 -Dl 1.67932833778 -1.09579946222 -Dl 1.71773186778 -1.00462743222 -Dl 1.74171835778 -0.906765042222 -Dl 1.75000002778 -0.803500072222 -Dl 1.75000002778 0.803499987556 -Dl 1.74171835778 0.906764959926 -Dl 1.71773186778 1.00462735636 -Dl 1.67932833778 1.09579939584 -Dl 1.62779554778 1.17899329736 -Dl 1.56442127778 1.25292127989 -Dl 1.49049330778 1.31629556242 -Dl 1.40729941778 1.36782836393 -Dl 1.31612738778 1.40623190342 -Dl 1.21826499778 1.43021839985 -Dl 1.11500002778 1.43850007222 -Dl -1.11500002778 1.43850007222 -Dl -1.21826499778 1.43021839985 -Dl -1.31612738778 1.40623190342 -Dl -1.40729941778 1.36782836393 -Dl -1.49049330778 1.31629556242 -Dl -1.56442127778 1.25292127989 -Dl -1.62779554778 1.17899329736 -Dl -1.67932833778 1.09579939584 -Dl -1.71773186778 1.00462735636 -Dl -1.74171835778 0.906764959926 -Dl -1.75000002778 0.803499987556 -Dl -1.75000002778 -0.803500072222 -Dl -1.74171835778 -0.906765042222 -Dl -1.71773186778 -1.00462743222 -Dl -1.67932833778 -1.09579946222 -Dl -1.62779554778 -1.17899335222 -Dl -1.56442127778 -1.25292132222 -Dl -1.49049330778 -1.31629559222 -Dl -1.40729941778 -1.36782838222 -Dl -1.31612738778 -1.40623191222 -Dl -1.21826499778 -1.43021840222 -Dl -1.11500002778 -1.43850007222 -DP 0 0 0 0 45 0.00254 15 -Dl -0.799826470222 -0.551812572889 -Dl -0.799826470222 0.551656560444 -Dl -0.565762111778 0.551656560444 -Dl -0.561958169057 0.551588736518 -Dl -0.557617048171 0.551401158364 -Dl -0.552923190034 0.551117666424 -Dl -0.548061035561 0.550762101138 -Dl -0.543215025667 0.550358302944 -Dl -0.538569601266 0.549930112284 -Dl -0.534309203273 0.549501369598 -Dl -0.530618272603 0.549095915324 -Dl -0.52768125017 0.548737589904 -Dl -0.525682576889 0.548450233778 -Dl -0.492995048016 0.540568430728 -Dl -0.462283746331 0.52830719253 -Dl -0.433893631435 0.512020431606 -Dl -0.40816966293 0.49206206038 -Dl -0.385456800417 0.468785991278 -Dl -0.366100003497 0.442546136722 -Dl -0.350444231772 0.413696409136 -Dl -0.338834444843 0.382590720946 -Dl -0.331615602311 0.349582984574 -Dl -0.329132663778 0.315027112444 -Dl -0.329132663778 -0.315022850889 -Dl -0.331662301481 -0.349341511769 -Dl -0.339000486336 -0.381745179104 -Dl -0.350771125962 -0.412034607335 -Dl -0.366598127977 -0.440010550903 -Dl -0.3861054 -0.46547376425 -Dl -0.40891684965 -0.488225001817 -Dl -0.434656384545 -0.508065018045 -Dl -0.462947912304 -0.524794567376 -Dl -0.493415340546 -0.538214404251 -Dl -0.525682576889 -0.548125283111 -Dl -0.528861655226 -0.548802540244 -Dl -0.532532627931 -0.549415840178 -Dl -0.536580107545 -0.549963455711 -Dl -0.54088870661 -0.550443659644 -Dl -0.545343037667 -0.550854724778 -Dl -0.549827713257 -0.551194923911 -Dl -0.554227345922 -0.551462529844 -Dl -0.558426548203 -0.551655815378 -Dl -0.562309932641 -0.551773053311 -Dl -0.565762111778 -0.551812516444 -Dl -0.799826470222 -0.551812572889 -DP 0 0 0 0 75 0.00254 15 -Dl -1.11853875578 -1.0274762681 -Dl -1.13990576822 -1.02618187506 -Dl -1.16015910005 -1.02243221638 -Dl -1.17903130383 -1.01642757278 -Dl -1.19625493213 -1.00836822494 -Dl -1.2115625375 -0.998454453586 -Dl -1.22468667251 -0.9868865394 -Dl -1.23535988973 -0.973864763088 -Dl -1.24331474171 -0.959589405351 -Dl -1.24828378102 -0.944260746888 -Dl -1.24999956022 -0.9280790684 -Dl -1.24999956022 0.927762714222 -Dl -1.24828378102 0.943953377266 -Dl -1.24331474171 0.959306407746 -Dl -1.23535988973 0.973617677884 -Dl -1.22468667251 0.986683059904 -Dl -1.2115625375 0.998298426028 -Dl -1.19625493213 1.00825964848 -Dl -1.17903130383 1.01636259948 -Dl -1.16015910005 1.02240315125 -Dl -1.13990576822 1.02617717602 -Dl -1.11853875578 1.027480546 -Dl -0.0133061004444 1.027480546 -Dl 0.008060891422 1.02617717602 -Dl 0.0283142113138 1.02240315125 -Dl 0.0471864102696 1.01636259948 -Dl 0.064410039328 1.00825964848 -Dl 0.0797176495278 0.998298426028 -Dl 0.0928417919076 0.986683059904 -Dl 0.103515017506 0.973617677884 -Dl 0.111469877362 0.959306407746 -Dl 0.116438922514 0.943953377266 -Dl 0.118154704 0.927762714222 -Dl 0.118154704 -0.9280790684 -Dl 0.116438922514 -0.944260746888 -Dl 0.111469877362 -0.959589405351 -Dl 0.103515017506 -0.973864763088 -Dl 0.0928417919076 -0.9868865394 -Dl 0.0797176495278 -0.998454453586 -Dl 0.064410039328 -1.00836822494 -Dl 0.0471864102696 -1.01642757278 -Dl 0.0283142113138 -1.02243221638 -Dl 0.008060891422 -1.02618187506 -Dl -0.0133061004444 -1.0274762681 -Dl -1.11853875578 -1.0274762681 -Dl -0.997979580533 -0.743713298222 -Dl -0.614498785111 -0.743713298222 -Dl -0.517025410222 -0.743713298222 -Dl -0.511574570222 -0.743713298222 -Dl -0.450075184458 -0.738785629162 -Dl -0.391801897737 -0.724512564011 -Dl -0.337519621914 -0.701659015808 -Dl -0.287993268846 -0.670989897596 -Dl -0.243987750389 -0.633270122417 -Dl -0.206267978398 -0.58926460331 -Dl -0.17559886473 -0.539738253318 -Dl -0.152745321241 -0.485455985483 -Dl -0.138472259786 -0.427182712844 -Dl -0.133544592222 -0.365683348444 -Dl -0.133544592222 0.365366977333 -Dl -0.138472259786 0.42687531338 -Dl -0.152745321241 0.485172952267 -Dl -0.17559886473 0.539491132513 -Dl -0.206267978398 0.58906109264 -Dl -0.243987750389 0.633114071167 -Dl -0.287993268846 0.670881306613 -Dl -0.337519621914 0.7015940375 -Dl -0.391801897737 0.724483502347 -Dl -0.450075184458 0.738780939673 -Dl -0.511574570222 0.743717588 -Dl -0.517025410222 0.743717588 -Dl -0.614498785111 0.743717588 -Dl -0.997979580533 0.743717588 -Dl -0.997979580533 -0.743713298222 -Dl -1.11853875578 -1.0274762681 -DP 0 0 0 0 70 0.00254 15 -Dl 0.391977936444 -0.747777337733 -Dl 0.382259953651 -0.746732845899 -Dl 0.373038393029 -0.74370875169 -Dl 0.364437270418 -0.738869127051 -Dl 0.356580601657 -0.732378043925 -Dl 0.349592402583 -0.724399574256 -Dl 0.343596689036 -0.715097789986 -Dl 0.338717476855 -0.70463676306 -Dl 0.335078781877 -0.693180565421 -Dl 0.332804619942 -0.680893269013 -Dl 0.332019006889 -0.667938945778 -Dl 0.332019006889 -0.624653084222 -Dl 0.332804618418 -0.611698760513 -Dl 0.335078776459 -0.599411462818 -Dl 0.338717466187 -0.587955263282 -Dl 0.34359667278 -0.577494234053 -Dl 0.349592381417 -0.568192447278 -Dl 0.356580577273 -0.560213975102 -Dl 0.364437245526 -0.553722889673 -Dl 0.373038371355 -0.548883263138 -Dl 0.382259939935 -0.545859167642 -Dl 0.391977936444 -0.544814675333 -Dl 0.693055256 -0.544814675333 -Dl 0.693055256 0.671999333333 -Dl 0.694042585976 0.684300999329 -Dl 0.69690207426 0.69596052674 -Dl 0.701479968386 0.706824163099 -Dl 0.70762251589 0.716738155943 -Dl 0.715175964306 0.725548752806 -Dl 0.723986561168 0.733102201221 -Dl 0.733900554012 0.739244748725 -Dl 0.744764190372 0.743822642852 -Dl 0.756423717782 0.746682131135 -Dl 0.768725383778 0.747669461111 -Dl 0.822912868889 0.747669461111 -Dl 0.835214534885 0.746682131135 -Dl 0.846874062295 0.743822642852 -Dl 0.857737698655 0.739244748725 -Dl 0.867651691499 0.733102201221 -Dl 0.876462288361 0.725548752806 -Dl 0.884015736777 0.716738155943 -Dl 0.890158284281 0.706824163099 -Dl 0.894736178407 0.69596052674 -Dl 0.897595666691 0.684300999329 -Dl 0.898582996667 0.671999333333 -Dl 0.898582996667 -0.544814675333 -Dl 1.19004122333 -0.544814675333 -Dl 1.19975921378 -0.545859167642 -Dl 1.20898078052 -0.548883263138 -Dl 1.2175819079 -0.553722889673 -Dl 1.22543858025 -0.560213975102 -Dl 1.23242678189 -0.568192447278 -Dl 1.23842249716 -0.577494234053 -Dl 1.24330171038 -0.587955263282 -Dl 1.2469404059 -0.599411462818 -Dl 1.24921456803 -0.611698760513 -Dl 1.25000018111 -0.624653084222 -Dl 1.25000018111 -0.667938945778 -Dl 1.24921456955 -0.680893269013 -Dl 1.24694041132 -0.693180565421 -Dl 1.24330172105 -0.70463676306 -Dl 1.23842251341 -0.715097789986 -Dl 1.23242680306 -0.724399574256 -Dl 1.22543860463 -0.732378043925 -Dl 1.21758193279 -0.738869127051 -Dl 1.2089808022 -0.74370875169 -Dl 1.19975922749 -0.746732845899 -Dl 1.19004122333 -0.747777337733 -Dl 0.391977936444 -0.747777337733 -Dl 0.391977936444 -0.747777337733 -$EndMODULE DT-Logo-Front -$MODULE DT-Logo-Back -Po 0 0 0 15 00000000 00000000 ~~ -Li DT-Logo-Back -T0 0 -4.48650007222 1.524 1.524 0 0.3048 N I 21 "DT-Logo-Back" -T1 0 4.48650007222 1.524 1.524 0 0.3048 N I 21 "G***" -DP 0 0 0 0 45 0.00254 22 -Dl 1.11500002778 -1.43850007222 -Dl -1.11500002778 -1.43850007222 -Dl -1.21826499778 -1.43021840222 -Dl -1.31612738778 -1.40623191222 -Dl -1.40729941778 -1.36782838222 -Dl -1.49049330778 -1.31629559222 -Dl -1.56442127778 -1.25292132222 -Dl -1.62779554778 -1.17899335222 -Dl -1.67932833778 -1.09579946222 -Dl -1.71773186778 -1.00462743222 -Dl -1.74171835778 -0.906765042222 -Dl -1.75000002778 -0.803500072222 -Dl -1.75000002778 0.803499987556 -Dl -1.74171835778 0.906764959926 -Dl -1.71773186778 1.00462735636 -Dl -1.67932833778 1.09579939584 -Dl -1.62779554778 1.17899329736 -Dl -1.56442127778 1.25292127989 -Dl -1.49049330778 1.31629556242 -Dl -1.40729941778 1.36782836393 -Dl -1.31612738778 1.40623190342 -Dl -1.21826499778 1.43021839985 -Dl -1.11500002778 1.43850007222 -Dl 1.11500002778 1.43850007222 -Dl 1.21826499778 1.43021839985 -Dl 1.31612738778 1.40623190342 -Dl 1.40729941778 1.36782836393 -Dl 1.49049330778 1.31629556242 -Dl 1.56442127778 1.25292127989 -Dl 1.62779554778 1.17899329736 -Dl 1.67932833778 1.09579939584 -Dl 1.71773186778 1.00462735636 -Dl 1.74171835778 0.906764959926 -Dl 1.75000002778 0.803499987556 -Dl 1.75000002778 -0.803500072222 -Dl 1.74171835778 -0.906765042222 -Dl 1.71773186778 -1.00462743222 -Dl 1.67932833778 -1.09579946222 -Dl 1.62779554778 -1.17899335222 -Dl 1.56442127778 -1.25292132222 -Dl 1.49049330778 -1.31629559222 -Dl 1.40729941778 -1.36782838222 -Dl 1.31612738778 -1.40623191222 -Dl 1.21826499778 -1.43021840222 -Dl 1.11500002778 -1.43850007222 -DP 0 0 0 0 45 0.00254 0 -Dl 0.799826470222 -0.551812572889 -Dl 0.799826470222 0.551656560444 -Dl 0.565762111778 0.551656560444 -Dl 0.561958169057 0.551588736518 -Dl 0.557617048171 0.551401158364 -Dl 0.552923190034 0.551117666424 -Dl 0.548061035561 0.550762101138 -Dl 0.543215025667 0.550358302944 -Dl 0.538569601266 0.549930112284 -Dl 0.534309203273 0.549501369598 -Dl 0.530618272603 0.549095915324 -Dl 0.52768125017 0.548737589904 -Dl 0.525682576889 0.548450233778 -Dl 0.492995048016 0.540568430728 -Dl 0.462283746331 0.52830719253 -Dl 0.433893631435 0.512020431606 -Dl 0.40816966293 0.49206206038 -Dl 0.385456800417 0.468785991278 -Dl 0.366100003497 0.442546136722 -Dl 0.350444231772 0.413696409136 -Dl 0.338834444843 0.382590720946 -Dl 0.331615602311 0.349582984574 -Dl 0.329132663778 0.315027112444 -Dl 0.329132663778 -0.315022850889 -Dl 0.331662301481 -0.349341511769 -Dl 0.339000486336 -0.381745179104 -Dl 0.350771125962 -0.412034607335 -Dl 0.366598127977 -0.440010550903 -Dl 0.3861054 -0.46547376425 -Dl 0.40891684965 -0.488225001817 -Dl 0.434656384545 -0.508065018045 -Dl 0.462947912304 -0.524794567376 -Dl 0.493415340546 -0.538214404251 -Dl 0.525682576889 -0.548125283111 -Dl 0.528861655226 -0.548802540244 -Dl 0.532532627931 -0.549415840178 -Dl 0.536580107545 -0.549963455711 -Dl 0.54088870661 -0.550443659644 -Dl 0.545343037667 -0.550854724778 -Dl 0.549827713257 -0.551194923911 -Dl 0.554227345922 -0.551462529844 -Dl 0.558426548203 -0.551655815378 -Dl 0.562309932641 -0.551773053311 -Dl 0.565762111778 -0.551812516444 -Dl 0.799826470222 -0.551812572889 -DP 0 0 0 0 75 0.00254 0 -Dl 1.11853875578 -1.0274762681 -Dl 1.13990576822 -1.02618187506 -Dl 1.16015910005 -1.02243221638 -Dl 1.17903130383 -1.01642757278 -Dl 1.19625493213 -1.00836822494 -Dl 1.2115625375 -0.998454453586 -Dl 1.22468667251 -0.9868865394 -Dl 1.23535988973 -0.973864763088 -Dl 1.24331474171 -0.959589405351 -Dl 1.24828378102 -0.944260746888 -Dl 1.24999956022 -0.9280790684 -Dl 1.24999956022 0.927762714222 -Dl 1.24828378102 0.943953377266 -Dl 1.24331474171 0.959306407746 -Dl 1.23535988973 0.973617677884 -Dl 1.22468667251 0.986683059904 -Dl 1.2115625375 0.998298426028 -Dl 1.19625493213 1.00825964848 -Dl 1.17903130383 1.01636259948 -Dl 1.16015910005 1.02240315125 -Dl 1.13990576822 1.02617717602 -Dl 1.11853875578 1.027480546 -Dl 0.0133061004444 1.027480546 -Dl -0.008060891422 1.02617717602 -Dl -0.0283142113138 1.02240315125 -Dl -0.0471864102696 1.01636259948 -Dl -0.064410039328 1.00825964848 -Dl -0.0797176495278 0.998298426028 -Dl -0.0928417919076 0.986683059904 -Dl -0.103515017506 0.973617677884 -Dl -0.111469877362 0.959306407746 -Dl -0.116438922514 0.943953377266 -Dl -0.118154704 0.927762714222 -Dl -0.118154704 -0.9280790684 -Dl -0.116438922514 -0.944260746888 -Dl -0.111469877362 -0.959589405351 -Dl -0.103515017506 -0.973864763088 -Dl -0.0928417919076 -0.9868865394 -Dl -0.0797176495278 -0.998454453586 -Dl -0.064410039328 -1.00836822494 -Dl -0.0471864102696 -1.01642757278 -Dl -0.0283142113138 -1.02243221638 -Dl -0.008060891422 -1.02618187506 -Dl 0.0133061004444 -1.0274762681 -Dl 1.11853875578 -1.0274762681 -Dl 0.997979580533 -0.743713298222 -Dl 0.614498785111 -0.743713298222 -Dl 0.517025410222 -0.743713298222 -Dl 0.511574570222 -0.743713298222 -Dl 0.450075184458 -0.738785629162 -Dl 0.391801897737 -0.724512564011 -Dl 0.337519621914 -0.701659015808 -Dl 0.287993268846 -0.670989897596 -Dl 0.243987750389 -0.633270122417 -Dl 0.206267978398 -0.58926460331 -Dl 0.17559886473 -0.539738253318 -Dl 0.152745321241 -0.485455985483 -Dl 0.138472259786 -0.427182712844 -Dl 0.133544592222 -0.365683348444 -Dl 0.133544592222 0.365366977333 -Dl 0.138472259786 0.42687531338 -Dl 0.152745321241 0.485172952267 -Dl 0.17559886473 0.539491132513 -Dl 0.206267978398 0.58906109264 -Dl 0.243987750389 0.633114071167 -Dl 0.287993268846 0.670881306613 -Dl 0.337519621914 0.7015940375 -Dl 0.391801897737 0.724483502347 -Dl 0.450075184458 0.738780939673 -Dl 0.511574570222 0.743717588 -Dl 0.517025410222 0.743717588 -Dl 0.614498785111 0.743717588 -Dl 0.997979580533 0.743717588 -Dl 0.997979580533 -0.743713298222 -Dl 1.11853875578 -1.0274762681 -DP 0 0 0 0 70 0.00254 0 -Dl -0.391977936444 -0.747777337733 -Dl -0.382259953651 -0.746732845899 -Dl -0.373038393029 -0.74370875169 -Dl -0.364437270418 -0.738869127051 -Dl -0.356580601657 -0.732378043925 -Dl -0.349592402583 -0.724399574256 -Dl -0.343596689036 -0.715097789986 -Dl -0.338717476855 -0.70463676306 -Dl -0.335078781877 -0.693180565421 -Dl -0.332804619942 -0.680893269013 -Dl -0.332019006889 -0.667938945778 -Dl -0.332019006889 -0.624653084222 -Dl -0.332804618418 -0.611698760513 -Dl -0.335078776459 -0.599411462818 -Dl -0.338717466187 -0.587955263282 -Dl -0.34359667278 -0.577494234053 -Dl -0.349592381417 -0.568192447278 -Dl -0.356580577273 -0.560213975102 -Dl -0.364437245526 -0.553722889673 -Dl -0.373038371355 -0.548883263138 -Dl -0.382259939935 -0.545859167642 -Dl -0.391977936444 -0.544814675333 -Dl -0.693055256 -0.544814675333 -Dl -0.693055256 0.671999333333 -Dl -0.694042585976 0.684300999329 -Dl -0.69690207426 0.69596052674 -Dl -0.701479968386 0.706824163099 -Dl -0.70762251589 0.716738155943 -Dl -0.715175964306 0.725548752806 -Dl -0.723986561168 0.733102201221 -Dl -0.733900554012 0.739244748725 -Dl -0.744764190372 0.743822642852 -Dl -0.756423717782 0.746682131135 -Dl -0.768725383778 0.747669461111 -Dl -0.822912868889 0.747669461111 -Dl -0.835214534885 0.746682131135 -Dl -0.846874062295 0.743822642852 -Dl -0.857737698655 0.739244748725 -Dl -0.867651691499 0.733102201221 -Dl -0.876462288361 0.725548752806 -Dl -0.884015736777 0.716738155943 -Dl -0.890158284281 0.706824163099 -Dl -0.894736178407 0.69596052674 -Dl -0.897595666691 0.684300999329 -Dl -0.898582996667 0.671999333333 -Dl -0.898582996667 -0.544814675333 -Dl -1.19004122333 -0.544814675333 -Dl -1.19975921378 -0.545859167642 -Dl -1.20898078052 -0.548883263138 -Dl -1.2175819079 -0.553722889673 -Dl -1.22543858025 -0.560213975102 -Dl -1.23242678189 -0.568192447278 -Dl -1.23842249716 -0.577494234053 -Dl -1.24330171038 -0.587955263282 -Dl -1.2469404059 -0.599411462818 -Dl -1.24921456803 -0.611698760513 -Dl -1.25000018111 -0.624653084222 -Dl -1.25000018111 -0.667938945778 -Dl -1.24921456955 -0.680893269013 -Dl -1.24694041132 -0.693180565421 -Dl -1.24330172105 -0.70463676306 -Dl -1.23842251341 -0.715097789986 -Dl -1.23242680306 -0.724399574256 -Dl -1.22543860463 -0.732378043925 -Dl -1.21758193279 -0.738869127051 -Dl -1.2089808022 -0.74370875169 -Dl -1.19975922749 -0.746732845899 -Dl -1.19004122333 -0.747777337733 -Dl -0.391977936444 -0.747777337733 -Dl -0.391977936444 -0.747777337733 -$EndMODULE DT-Logo-Back -$EndLIBRARY \ No newline at end of file diff --git a/examples/dt-logo.svg b/examples/dt-logo.svg deleted file mode 100644 index 9987a91..0000000 --- a/examples/dt-logo.svg +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/examples/svg2mod.svg b/examples/svg2mod.svg new file mode 100644 index 0000000..4ab9c93 --- /dev/null +++ b/examples/svg2mod.svg @@ -0,0 +1,205 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d48d073 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,8 @@ +[aliases] +test=pytest + +[tool:pytest] +addopts = --pylint --pylint-rcfile=.linter/required-linting.rc + +[metadata] +license_files = LICENSE \ No newline at end of file diff --git a/setup.py b/setup.py index d51ddd7..df03109 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,11 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +''' +to install system wide use `pip install .` +testing is done via `python setup.py test` +''' +import subprocess import setuptools @@ -13,39 +18,58 @@ with open('README.md') as readme_file: readme = readme_file.read() +tag = "" + +try: + ps = subprocess.check_output(["git","describe","--tag"], stderr=subprocess.STDOUT) + tag = ps.decode('utf-8').strip() + tag = tag.replace("-", ".dev", 1).replace("-", "+") +except: + tag = "0.dev0" + requirements = [ + "fonttools" ] +setup_requirements = [ + "pytest-runner", "pytest-pylint", +] test_requirements = [ - # TODO: put package test requirements here + "pytest", "pylint", "pyenchant", ] setup( name='svg2mod', - version='0.1.0', + version=tag, description="Convert an SVG file to a KiCad footprint.", + long_description_content_type='text/markdown', long_description=readme, - author='https://github.com/mtl', + author='https://github.com/svg2mod', author_email='', - url='https://github.com/mtl/svg2mod', + url='https://github.com/svg2mod/svg2mod', packages=setuptools.find_packages(), - entry_points={'console_scripts':['svg2mod = svg2mod.svg2mod:main']}, + entry_points={'console_scripts':['svg2mod = svg2mod.cli:main']}, package_dir={'svg2mod':'svg2mod'}, include_package_data=True, package_data={'kipart': ['*.gif', '*.png']}, scripts=[], install_requires=requirements, - license="CC0-1.0", + license="GPLv2+", zip_safe=False, - keywords='svg2mod, KiCAD', + keywords='svg2mod, KiCAD, inkscape', classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication', + 'Intended Audience :: Developers', + 'Intended Audience :: Manufacturing', + 'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)', 'Natural Language :: English', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', ], - test_suite='tests', + setup_requires=setup_requirements, + test_suite='test', tests_require=test_requirements ) diff --git a/svg2mod/__init__.py b/svg2mod/__init__.py index e69de29..7b3e7ec 100644 --- a/svg2mod/__init__.py +++ b/svg2mod/__init__.py @@ -0,0 +1,23 @@ +# Copyright (C) 2022 -- svg2mod developers < GitHub.com / svg2mod > + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +''' +This module contains the necessary tools to convert from +the svg objects provided from the svg2mod.svg module to +KiCad file formats. +This currently supports both the pretty format and +the legacy mod format. +''' \ No newline at end of file diff --git a/svg2mod/cli.py b/svg2mod/cli.py new file mode 100644 index 0000000..1ace1c8 --- /dev/null +++ b/svg2mod/cli.py @@ -0,0 +1,344 @@ +# Copyright (C) 2022 -- svg2mod developers < GitHub.com / svg2mod > + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +''' +The command line interface for using svg2mod +as a tool in a terminal. +''' + +import argparse +import logging +import os +import shlex +import sys +import traceback + +import svg2mod.coloredlogger as coloredlogger +from svg2mod.coloredlogger import logger, unfiltered_logger +from svg2mod import svg +from svg2mod.exporter import (DEFAULT_DPI, Svg2ModExportLatest, + Svg2ModExportLegacy, Svg2ModExportLegacyUpdater, + Svg2ModExportPretty) +from svg2mod.importer import Svg2ModImport + +#---------------------------------------------------------------------------- + +def main(): + '''This function handles the scripting package calls. + It is setup to read the arguments from `get_arguments()` + then parse the target svg file and output all converted + objects into a kicad footprint module. + ''' + + args,_ = get_arguments() + + + # Setup root logger to use terminal colored outputs as well as stdout and stderr + coloredlogger.split_logger(logger) + + if args.debug_print: + logger.setLevel(logging.DEBUG) + elif args.verbose_print: + logger.setLevel(logging.INFO) + else: + logger.setLevel(logging.WARNING) + + if args.input_file_name_flag and not args.input_file_name: + args.input_file_name = args.input_file_name_flag + + if args.list_fonts: + fonts = svg.Text.load_system_fonts() + unfiltered_logger.info("Font Name: list of supported styles.") + for font in fonts: + fnt_text = f" {font}:" + for styles in fonts[font]: + fnt_text += f" {styles}," + fnt_text = fnt_text.strip(",") + unfiltered_logger.info(fnt_text) + sys.exit(0) + if args.default_font: + svg.Text.default_font = args.default_font + + pretty = args.format in ['pretty','latest'] + use_mm = args.units == 'mm' + + if pretty: + + if not use_mm: + logger.critical("Error: decimal units only allowed with legacy output type") + sys.exit( -1 ) + + try: + # Import the SVG: + imported = Svg2ModImport( + args.input_file_name, + args.module_name, + args.module_value, + args.ignore_hidden, + args.force_layer + ) + + # Pick an output file name if none was provided: + if args.output_file_name is None: + + args.output_file_name = os.path.splitext( + os.path.basename( args.input_file_name ) + )[ 0 ] + + # Append the correct file name extension if needed: + if pretty: + extension = ".kicad_mod" + else: + extension = ".mod" + if args.output_file_name[ - len( extension ) : ] != extension: + args.output_file_name += extension + + # Create an exporter: + if pretty: + exported = (Svg2ModExportPretty if args.format == "pretty" else Svg2ModExportLatest)( + imported, + args.output_file_name, + args.center, + args.scale_factor, + args.precision, + dpi = args.dpi, + pads = args.convert_to_pads, + ) + + else: + + # If the module file exists, try to read it: + exported = None + if os.path.isfile( args.output_file_name ): + + try: + exported = Svg2ModExportLegacyUpdater( + imported, + args.output_file_name, + args.center, + args.scale_factor, + args.precision, + args.dpi, + ) + + except Exception as e: + raise e + + # Write the module file: + if exported is None: + exported = Svg2ModExportLegacy( + imported, + args.output_file_name, + args.center, + args.scale_factor, + args.precision, + use_mm = use_mm, + dpi = args.dpi, + ) + + cmd_args = [os.path.basename(sys.argv[0])] + sys.argv[1:] + cmdline = ' '.join(shlex.quote(x) for x in cmd_args) + + # Export the footprint: + exported.write(cmdline) + except Exception as e: + if args.debug_print: + traceback.print_exc() + else: + logger.critical(f'Unhandled exception (Exiting)\n {type(e).__name__}: {e} ') + exit(-1) + +#---------------------------------------------------------------------------- + +def get_arguments(): + ''' Return an instance of pythons argument parser + with all the command line functionalities arguments + ''' + + parser = argparse.ArgumentParser( + description = ( + 'Convert Inkscape SVG drawings to KiCad footprint modules.' + ) + ) + + mux = parser.add_mutually_exclusive_group(required=True) + + mux.add_argument( + nargs="?", + type = str, + dest = 'input_file_name', + metavar = 'IN_FILENAME', + help = "Name of the SVG file", + ) + + mux.add_argument( + '-i', '--input-file', + type = str, + dest = 'input_file_name_flag', + metavar = 'FILENAME', + help = "Name of the SVG file, but specified with a flag.", + ) + + parser.add_argument( + '-o', '--output-file', + type = str, + dest = 'output_file_name', + metavar = 'FILENAME', + help = "Name of the module file", + ) + + parser.add_argument( + '-c', '--center', + dest = 'center', + action = 'store_const', + const = True, + help = "Center the module to the center of the bounding box", + default = False, + ) + + parser.add_argument( + '-P', '--convert-pads', + dest = 'convert_to_pads', + action = 'store_const', + const = True, + help = "Convert any artwork on Cu layers to pads", + default = False, + ) + + parser.add_argument( + '-v', '--verbose', + dest = 'verbose_print', + action = 'store_const', + const = True, + help = "Print more verbose messages", + default = False, + ) + + parser.add_argument( + '--debug', + dest = 'debug_print', + action = 'store_const', + const = True, + help = "Print debug level messages", + default = False, + ) + + parser.add_argument( + '-x', '--exclude-hidden', + dest = 'ignore_hidden', + action = 'store_const', + const = True, + help = "Do not export hidden objects", + default = False, + ) + + parser.add_argument( + '--force', '--force-layer', + type = str, + dest = 'force_layer', + metavar = 'LAYER', + help = "Force everything into the single provided layer", + default = None, + ) + + parser.add_argument( + '-d', '--dpi', + type = int, + dest = 'dpi', + metavar = 'DPI', + help = "DPI of the SVG file (int)", + default = DEFAULT_DPI, + ) + + parser.add_argument( + '-f', '--factor', + type = float, + dest = 'scale_factor', + metavar = 'FACTOR', + help = "Scale paths by this factor", + default = 1.0, + ) + + parser.add_argument( + '-p', '--precision', + type = float, + dest = 'precision', + metavar = 'PRECISION', + help = "Smoothness for approximating curves with line segments. Input is the approximate length for each line segment in SVG pixels (float)", + default = 5.0, + ) + parser.add_argument( + '--format', + type = str, + dest = 'format', + metavar = 'FORMAT', + choices = [ 'legacy', 'pretty', 'latest'], + help = "Output module file format (legacy|pretty|latest). 'latest' introduces features used in kicad >= 6", + default = 'latest', + ) + + parser.add_argument( + '--name', '--module-name', + type = str, + dest = 'module_name', + metavar = 'NAME', + help = "Base name of the module", + default = "svg2mod", + ) + + parser.add_argument( + '--units', + type = str, + dest = 'units', + metavar = 'UNITS', + choices = [ 'decimal', 'mm' ], + help = "Output units, if output format is legacy (decimal|mm)", + default = 'mm', + ) + + parser.add_argument( + '--value', '--module-value', + type = str, + dest = 'module_value', + metavar = 'VALUE', + help = "Value of the module", + default = "G***", + ) + + parser.add_argument( + '-F', '--default-font', + type = str, + dest = 'default_font', + help = "Default font to use if the target font in a text element cannot be found", + ) + + mux.add_argument( + '-l', '--list-fonts', + dest = 'list_fonts', + const = True, + default = False, + action = "store_const", + help = "List all fonts that can be found in common locations", + ) + + return parser.parse_args(), parser + +#---------------------------------------------------------------------------- + +if __name__ == "__main__": + main() + +#---------------------------------------------------------------------------- diff --git a/svg2mod/coloredlogger.py b/svg2mod/coloredlogger.py new file mode 100644 index 0000000..5101e59 --- /dev/null +++ b/svg2mod/coloredlogger.py @@ -0,0 +1,105 @@ +# Copyright (C) 2022 -- svg2mod developers < GitHub.com / svg2mod > + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +''' +A simple modification to the formatter class in the python logger to allow +ANSI color codes based on the logged message's level +''' + +import logging +import sys + +#---------------------------------------------------------------------------- + +#Setup and configure svg2mod and svg2mod-unfiltered loggers + +logger = logging.getLogger("svg2mod") +unfiltered_logger = logging.getLogger("svg2mod-unfiltered") +_sh = logging.StreamHandler(sys.stdout) + +logger.addHandler(_sh) +unfiltered_logger.addHandler(_sh) + +logger.setLevel(logging.DEBUG) + +# Add a second logger that will bypass the log level and output anyway +# It is a good practice to send only messages level INFO via this logger +unfiltered_logger.setLevel(logging.INFO) + +# This can be used sparingly as follows: +#--------- +# unfiltered_logger.info("Message Here") +#--------- + + +#---------------------------------------------------------------------------- + +class Formatter(logging.Formatter): + '''Extend formatter to add colored output functionality ''' + + # ASCII escape codes for supporting terminals + color = { + logging.CRITICAL: "\033[91m\033[7m", #Set red and swap background and foreground + logging.ERROR: "\033[91m", #Set red + logging.WARNING: "\033[93m", #Set yellow + logging.DEBUG: "\033[90m", #Set dark gray/black + logging.INFO: "" #Do nothing + } + reset = "\033[0m" # Reset the terminal back to default color/emphasis + + #------------------------------------------------------------------------ + + def __init__(self, fmt="%(message)s", datefmt=None, style="%"): + super().__init__(fmt, datefmt, style) + + #------------------------------------------------------------------------ + + def format(self, record): + '''Overwrite the format function. + This saves the original style, overwrites it to support + color, sends the message to the super.format, and + finally returns the style to the original format + ''' + if sys.stdout.isatty(): + fmt_org = self._style._fmt + self._style._fmt = Formatter.color[record.levelno] + fmt_org + Formatter.reset + result = logging.Formatter.format(self, record) + if sys.stdout.isatty(): + self._style._fmt = fmt_org + return result + + #------------------------------------------------------------------------ + +#---------------------------------------------------------------------------- + +def split_logger(logger, formatter=Formatter(), break_point=logging.WARNING): + '''This will split logging messages at the specified break point. Anything higher + will be sent to sys.stderr and everything else to sys.stdout + ''' + for handler in logger.handlers: + logger.removeHandler(handler) + + handler_error = logging.StreamHandler(sys.stderr) + handler_error.addFilter(lambda msg: break_point <= msg.levelno) + + handler_out = logging.StreamHandler(sys.stdout) + handler_out.addFilter(lambda msg: break_point > msg.levelno) + + handler_error.setFormatter(formatter) + handler_out.setFormatter(formatter) + logger.addHandler(handler_error) + logger.addHandler(handler_out) + +#---------------------------------------------------------------------------- \ No newline at end of file diff --git a/svg2mod/exporter.py b/svg2mod/exporter.py new file mode 100644 index 0000000..5f78a53 --- /dev/null +++ b/svg2mod/exporter.py @@ -0,0 +1,1438 @@ +# Copyright (C) 2022 -- svg2mod developers < GitHub.com / svg2mod > + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +''' +Tools to convert data from Svg2ModImport to +the file information used in kicad module files. +''' + + +import copy +import datetime +import io +import json +import os +import re +import time +from abc import ABC, abstractmethod + +from svg2mod import svg +from svg2mod.coloredlogger import logger, unfiltered_logger +from svg2mod.importer import Svg2ModImport +from svg2mod.svg2mod import PolygonSegment + +#---------------------------------------------------------------------------- + +DEFAULT_DPI = 96 # 96 as of Inkscape 0.92 +MINIMUM_SIZE = 1e-5 # Minimum size kicad will render + +#---------------------------------------------------------------------------- + +class Svg2ModExport(ABC): + ''' An abstract class to provide functionality + to write to kicad module file. + The abstract methods are the file type specific + example: pretty, legacy + ''' + + #------------------------------------------------------------------------ + + @property + @abstractmethod + def layer_map(self ): + ''' This should be overwritten by a dictionary object of layer maps ''' + pass + + @abstractmethod + def _get_layer_name( self, item_name, name, front ):pass + + @abstractmethod + def _write_library_intro( self, cmdline ): pass + + @abstractmethod + def _get_module_name( self, front = None ): pass + + @abstractmethod + def _write_module_header( self, label_size, label_pen, reference_y, value_y, front,): pass + + @abstractmethod + def _write_modules( self ): pass + + @abstractmethod + def _write_module_footer( self, front ):pass + + @abstractmethod + def _write_polygon_header( self, points, layer ):pass + + @abstractmethod + def _write_polygon_footer( self, layer, stroke_width, fill=True):pass + + @abstractmethod + def _write_polygon_point( self, point ):pass + + @abstractmethod + def _write_polygon_segment( self, p, q, layer, stroke_width ):pass + + @abstractmethod + def _write_thru_hole( self, circle, layer ):pass + + #------------------------------------------------------------------------ + + @staticmethod + def _convert_decimal_to_mm( decimal ): + return float( decimal ) * 0.00254 + + + #------------------------------------------------------------------------ + + @staticmethod + def _convert_mm_to_decimal( mm ): + return int( round( mm * 393.700787 ) ) + + + #------------------------------------------------------------------------ + + def _get_fill_stroke( self, item ): + + s = item.style + + fill = False if not s.get('fill') or s["fill"] == "none" else True + fill = fill if not s.get('fill-opacity') or float(s['fill-opacity']) != 0 else False + + stroke = False if not s.get('stroke') or s["stroke"] == "none" else True + stroke = stroke if not s.get('stroke-opacity') or float(s['stroke-opacity']) != 0 else False + + stroke_width = s["stroke-width"] * self.scale_factor if s.get('stroke-width') else MINIMUM_SIZE + + if stroke_width is None: + stroke_width = 0 + + # This should display something. + if not self.imported.ignore_hidden and not fill and not stroke: + stroke = True + stroke_width = stroke_width if stroke_width else MINIMUM_SIZE + + # There should be no stroke_width if no stroke + return fill, stroke, stroke_width if stroke else 0 + + + #------------------------------------------------------------------------ + + def __init__( + self, + svg2mod_import = Svg2ModImport(), + file_name = None, + center = False, + scale_factor = 1.0, + precision = 20.0, + use_mm = True, + dpi = DEFAULT_DPI, + pads = False, + ): + if use_mm: + # 25.4 mm/in; + scale_factor *= 25.4 / float(dpi) + use_mm = True + else: + # PCBNew uses decimal (10K DPI); + scale_factor *= 10000.0 / float(dpi) + + self.imported = svg2mod_import + self.file_name = file_name + self.center = center + self.scale_factor = scale_factor + self.precision = precision + self.use_mm = use_mm + self.dpi = dpi + self.convert_pads = pads + + # Local instance variables + self.translation = None + self.layers = {} + self.output_file = None + self.raw_file_data = None + + + #------------------------------------------------------------------------ + + def add_svg_element(self, elem : svg.Transformable, layer="F.SilkS"): + ''' This can be used to add a svg element + to a specific layer. + If the importer doesn't have a svg element + it will also create an empty Svg object. + ''' + grp = svg.Group() + grp.name = layer + grp.items.append(elem) + try: + self.imported.svg.items.append(grp) + except AttributeError: + self.imported.svg = svg.Svg() + self.imported.svg.items.append(grp) + + #------------------------------------------------------------------------ + + + def _calculate_translation( self ): + + min_point, max_point = self.imported.svg.bbox() + + if self.center: + # Center the drawing: + adjust_x = min_point.x + ( max_point.x - min_point.x ) / 2.0 + adjust_y = min_point.y + ( max_point.y - min_point.y ) / 2.0 + + self.translation = svg.Point( + 0.0 - adjust_x, + 0.0 - adjust_y, + ) + + else: + self.translation = svg.Point( + 0.0, + 0.0, + ) + + #------------------------------------------------------------------------ + + def _prune( self, items = None ): + '''Find and keep only the layers of interest.''' + + empty_group_exception = items is None + + if items is None: + + self.layers = {} + for name in self.layer_map.keys(): + self.layers[ name ] = [] + + + items = self.imported.svg.items + self.imported.svg.items = [] + + kept_layers = {} + + for item in items: + + if not hasattr(item, 'name'): + continue + + i_name = item.name.split(":", 1) + + for name in self.layers.keys(): + # if name == i_name[0] and i_name[0] != "": + if re.match( '^{}$'.format(name), i_name[0]): + # Don't add empty groups to the list of valid items + if isinstance(item, svg.Group) and not item.items: + break + + if kept_layers.get(i_name[0]): + kept_layers[i_name[0]].append(item.name) + else: + kept_layers[i_name[0]] = [item.name] + + # Item isn't a group so make it one + if not isinstance(item, svg.Group): + grp = svg.Group() + grp.name = item.name + grp.items.append( item ) + item = grp + + # save valid groups + self.imported.svg.items.append( item ) + self.layers[name].append((i_name, item)) + break + else: + self._prune( item.items ) + + for kept in sorted(kept_layers.keys()): + unfiltered_logger.info( "Found SVG layer: {}".format( kept ) ) + logger.debug( " Detailed names: [{}]".format( ", ".join(kept_layers[kept]) ) ) + + # There are no elements to write so don't write + if empty_group_exception: + for name in self.layers: + if self.layers[name]: + break + else: + logger.warning("No valid items found. Maybe try --force Layer.Name") + raise Exception("Not writing empty file. No valid items found.") + + #------------------------------------------------------------------------ + + def _write_items( self, items, layer, flip = False ): + + for item in items: + + if isinstance( item, svg.Group ): + self._write_items( item.items, layer, flip ) + continue + + if re.match(r"^Drill\.\w+", str(layer)): + if isinstance(item, (svg.Circle, svg.Ellipse)): + self._write_thru_hole(item, layer) + else: + logger.warning( "Non Circle SVG element in drill layer: {}".format(item.__class__.__name__)) + + elif isinstance( item, (svg.Path, svg.Ellipse, svg.Rect, svg.Text, svg.Polygon)): + + segments = [ + PolygonSegment( segment ) + for segment in item.segments( + precision = self.precision + ) + ] + + fill, stroke, stroke_width = self._get_fill_stroke( item ) + if layer == "Edge.Cuts": + fill = False + stroke = True + stroke_width = MINIMUM_SIZE if stroke_width < MINIMUM_SIZE else stroke_width + + + fill = (True if re.match("^Keepout", str(layer)) else fill) + stroke_width = (0.508 if re.match("^Keepout", str(layer)) else stroke_width) + + for segment in segments: + segment.process( self, flip, fill ) + + if len( segments ) > 1: + # Sort segments in order of size + segments.sort(key=lambda v: svg.Segment(v.bbox[0], v.bbox[1]).length(), reverse=True) + + # Write all segments + while len(segments) > 0: + inlinable = [segments[0]] + + # Search to see if any paths are contained in the current shape + for seg in segments[1:]: + # Contained in parent shape + if fill and not inlinable[0].are_distinct(seg): + append = True + if len(inlinable) > 1: + for hole in inlinable[1:]: + # Contained in a hole. It is separate + if not hole.are_distinct(seg): + append = False + break + if append: inlinable.append(seg) + for poly in inlinable: + segments.pop(segments.index(poly)) + if len(inlinable) > 1: + points = inlinable[ 0 ].inline( inlinable[ 1 : ] ) + elif len(inlinable) > 0: + points = inlinable[ 0 ].points + + logger.info( " Writing {} with {} points".format(item.__class__.__name__, len( points ) )) + + self._write_polygon( + points, layer, fill, stroke, stroke_width + ) + continue + + if len( segments ) > 0: + points = segments[ 0 ].points + + if len ( segments ) == 1: + + logger.info( " Writing {} with {} points".format(item.__class__.__name__, len( points ) )) + + self._write_polygon( + points, layer, fill, stroke, stroke_width + ) + else: + logger.info( " Skipping {} with 0 points".format(item.__class__.__name__)) + + else: + logger.warning( "Unsupported SVG element: {}".format(item.__class__.__name__)) + + + #------------------------------------------------------------------------ + + def _write_module( self, front ): + + module_name = self._get_module_name( front ) + + min_point, max_point = self.imported.svg.bbox() + min_point = self.transform_point( min_point, flip = False ) + max_point = self.transform_point( max_point, flip = False ) + + label_offset = 1200 + label_size = 600 + label_pen = 120 + + if self.use_mm: + label_size = self._convert_decimal_to_mm( label_size ) + label_pen = self._convert_decimal_to_mm( label_pen ) + reference_y = min_point.y - self._convert_decimal_to_mm( label_offset ) + value_y = max_point.y + self._convert_decimal_to_mm( label_offset ) + else: + reference_y = min_point.y - label_offset + value_y = max_point.y + label_offset + + self._write_module_header( + label_size, label_pen, + reference_y, value_y, + front, + ) + + for name, groups in self.layers.items(): + for i_name, group in groups: + + if group is None: continue + + layer = self._get_layer_name( i_name, name, front ) + + self._write_items( group.items, layer, not front ) + + self._write_module_footer( front ) + + + #------------------------------------------------------------------------ + + def _write_polygon( self, points, layer, fill, stroke, stroke_width ): + + if fill and len(points) > 2: + self._write_polygon_filled( + points, layer, stroke_width + ) + return + + # Polygons with a fill and stroke are drawn with the filled polygon above + if stroke: + if len(points) == 1: + points.append(copy.copy(points[0])) + + self._write_polygon_outline( + points, layer, stroke_width + ) + return + + if len(points) < 3: + logger.debug(" Not writing non-polygon with no stroke.") + else: + logger.debug(" Polygon has no stroke or fill. Skipping.") + + + #------------------------------------------------------------------------ + + + def _write_polygon_filled( self, points, layer, stroke_width = 0.0 ): + + self._write_polygon_header( points, layer ) + + for point in points: + self._write_polygon_point( point ) + + self._write_polygon_footer( layer, stroke_width ) + + + #------------------------------------------------------------------------ + + def _write_polygon_outline( self, points, layer, stroke_width ): + + prior_point = None + for point in points: + + if prior_point is not None: + + self._write_polygon_segment( + prior_point, point, layer, stroke_width + ) + + prior_point = point + + + #------------------------------------------------------------------------ + + def transform_point( self, point, flip = False ): + ''' Transform provided point by this + classes scale factor. + ''' + + transformed_point = svg.Point( + ( point.x + self.translation.x ) * self.scale_factor, + ( point.y + self.translation.y ) * self.scale_factor, + ) + + if flip: + transformed_point.x *= -1 + + if self.use_mm: + transformed_point.x = transformed_point.x + transformed_point.y = transformed_point.y + else: + transformed_point.x = int( round( transformed_point.x ) ) + transformed_point.y = int( round( transformed_point.y ) ) + + return transformed_point + + + #------------------------------------------------------------------------ + + def write( self, cmdline="scripting" ): + '''Write the kicad footprint file. + The value from the command line argument + is set in a comment in the header of the file. + + If self.file_name is not null then this will + overwrite the target file with the data provided. + However if it is null then all data is written + to the string IO class (for same API as writing) + then dumped into self.raw_file_data before the + writer is closed. + ''' + + self._prune() + + # Must come after pruning: + self._calculate_translation() + + if self.file_name: + unfiltered_logger.info( "Writing module file: {}".format( self.file_name ) ) + self.output_file = open( self.file_name, 'w' ) + else: + self.output_file = io.StringIO() + + self._write_library_intro(cmdline) + + self._write_modules() + + if self.file_name is None: + self.raw_file_data = self.output_file.getvalue() + + self.output_file.close() + self.output_file = None + + + #------------------------------------------------------------------------ + +#---------------------------------------------------------------------------- + +class Svg2ModExportLegacy( Svg2ModExport ): + ''' A child of Svg2ModExport that implements + specific functionality for kicad legacy file types + ''' + + layer_map = { + #'inkscape-name' : [ kicad-front, kicad-back ], + 'F.Cu' : [ 15, 15 ], + 'B.Cu' : [ 0, 0 ], + 'F.Adhes' : [ 17, 17 ], + 'B.Adhes' : [ 16, 16 ], + 'F.Paste' : [ 19, 19 ], + 'B.Paste' : [ 18, 18 ], + 'F.SilkS' : [ 21, 21 ], + 'B.SilkS' : [ 20, 20 ], + 'F.Mask' : [ 23, 23 ], + 'B.Mask' : [ 22, 22 ], + 'Dwgs.User' : [ 24, 24 ], + 'Cmts.User' : [ 25, 25 ], + 'Eco1.User' : [ 26, 26 ], + 'Eco2.User' : [ 27, 27 ], + 'Edge.Cuts' : [ 28, 28 ], + } + + + #------------------------------------------------------------------------ + + def __init__( + self, + svg2mod_import, + file_name, + center, + scale_factor = 1.0, + precision = 20.0, + use_mm = True, + dpi = DEFAULT_DPI, + ): + super( Svg2ModExportLegacy, self ).__init__( + svg2mod_import, + file_name, + center, + scale_factor, + precision, + use_mm, + dpi, + pads = False, + ) + + self.include_reverse = True + + + #------------------------------------------------------------------------ + + def _get_layer_name( self, item_name, name, front ): + + layer_info = self.layer_map[ name ] + layer = layer_info[ 0 ] + if not front and layer_info[ 1 ] is not None: + layer = layer_info[ 1 ] + + return layer + + + #------------------------------------------------------------------------ + + def _get_module_name( self, front = None ): + + if self.include_reverse and not front: + return self.imported.module_name + "-rev" + + return self.imported.module_name + + + #------------------------------------------------------------------------ + + def _write_library_intro( self, cmdline ): + + modules_list = self._get_module_name( front = True ) + if self.include_reverse: + modules_list += ( + "\n" + + self._get_module_name( front = False ) + ) + + units = "" + if self.use_mm: + units = "\nUnits mm" + + self.output_file.write( """PCBNEW-LibModule-V1 {0}{1} +$INDEX +{2} +$EndINDEX +# +# Converted using: {3} +# +""".format( + datetime.datetime.now().strftime( "%a %d %b %Y %I:%M:%S %p %Z" ), + units, + modules_list, + cmdline.replace("\\","\\\\") + ) + ) + + + #------------------------------------------------------------------------ + + def _write_module_header( + self, label_size, label_pen, + reference_y, value_y, front, + ): + + self.output_file.write( """$MODULE {0} +Po 0 0 0 {6} 00000000 00000000 ~~ +Li {0} +T0 0 {1} {2} {2} 0 {3} N I 21 "{0}" +T1 0 {5} {2} {2} 0 {3} N I 21 "{4}" +""".format( + self._get_module_name( front ), #0 + reference_y, #1 + label_size, #2 + label_pen, #3 + self.imported.module_value, #4 + value_y, #5 + 15, # Seems necessary #6 + ) + ) + + + #------------------------------------------------------------------------ + + def _write_module_footer( self, front ): + + self.output_file.write( + "$EndMODULE {0}\n".format( self._get_module_name( front ) ) + ) + + + #------------------------------------------------------------------------ + + def _write_modules( self ): + + self._write_module( front = True ) + + if self.include_reverse: + self._write_module( front = False ) + + self.output_file.write( "$EndLIBRARY" ) + + + #------------------------------------------------------------------------ + + def _write_polygon_footer( self, layer, stroke_width, fill=True ): + + pass + + + #------------------------------------------------------------------------ + + def _write_polygon_header( self, points, layer ): + + pen = 1 + if self.use_mm: + pen = self._convert_decimal_to_mm( pen ) + + self.output_file.write( "DP 0 0 0 0 {} {} {}\n".format( + len( points ), + pen, + layer + ) ) + + + #------------------------------------------------------------------------ + + def _write_polygon_point( self, point ): + + self.output_file.write( + "Dl {} {}\n".format( point.x, point.y ) + ) + + + #------------------------------------------------------------------------ + + def _write_polygon_segment( self, p, q, layer, stroke_width ): + + self.output_file.write( "DS {} {} {} {} {} {}\n".format( + p.x, p.y, + q.x, q.y, + stroke_width, + layer + ) ) + + + #------------------------------------------------------------------------ + + def _write_thru_hole( self, circle, layer ): + + pass + + #------------------------------------------------------------------------ + +#---------------------------------------------------------------------------- + +class Svg2ModExportLegacyUpdater( Svg2ModExportLegacy ): + ''' A Svg2Mod exporter class that reads some settings + from an already existing module and will append its + changes to the file. + ''' + + #------------------------------------------------------------------------ + + def __init__( + self, + svg2mod_import, + file_name, + center, + scale_factor = 1.0, + precision = 20.0, + dpi = DEFAULT_DPI, + include_reverse = True, + ): + self.file_name = file_name + use_mm = self._parse_output_file() + + super( Svg2ModExportLegacyUpdater, self ).__init__( + svg2mod_import, + file_name, + center, + scale_factor, + precision, + use_mm, + dpi, + ) + + + #------------------------------------------------------------------------ + + def _parse_output_file( self ): + + logger.info( "Parsing module file: {}".format( self.file_name ) ) + module_file = open( self.file_name, 'r' ) + lines = module_file.readlines() + module_file.close() + + self.loaded_modules = {} + self.post_index = [] + self.pre_index = [] + use_mm = False + + index = 0 + + # Find the start of the index: + while index < len( lines ): + + line = lines[ index ] + index += 1 + self.pre_index.append( line ) + if line[ : 6 ] == "$INDEX": + break + + m = re.match( r"Units[\s]+mm[\s]*", line ) + if m is not None: + use_mm = True + + # Read the index: + while index < len( lines ): + + line = lines[ index ] + if line[ : 9 ] == "$EndINDEX": + break + index += 1 + self.loaded_modules[ line.strip() ] = [] + + # Read up until the first module: + while index < len( lines ): + + line = lines[ index ] + if line[ : 7 ] == "$MODULE": + break + index += 1 + self.post_index.append( line ) + + # Read modules: + while index < len( lines ): + + line = lines[ index ] + if line[ : 7 ] == "$MODULE": + module_name, module_lines, index = self._read_module( lines, index ) + if module_name is not None: + self.loaded_modules[ module_name ] = module_lines + + elif line[ : 11 ] == "$EndLIBRARY": + break + + else: + raise Exception( + "Expected $EndLIBRARY: [{}]".format( line ) + ) + + return use_mm + + + #------------------------------------------------------------------------ + + def _read_module( self, lines, index ): + + # Read module name: + m = re.match( r'\$MODULE[\s]+([^\s]+)[\s]*', lines[ index ] ) + module_name = m.group( 1 ) + + logger.info( " Reading module {}".format( module_name ) ) + + index += 1 + module_lines = [] + while index < len( lines ): + + line = lines[ index ] + index += 1 + + m = re.match( + r'\$EndMODULE[\s]+' + module_name + r'[\s]*', line + ) + if m is not None: + return module_name, module_lines, index + + module_lines.append( line ) + + raise Exception( + "Could not find end of module '{}'".format( module_name ) + ) + + + #------------------------------------------------------------------------ + + def _write_library_intro( self, cmdline ): + + # Write pre-index: + self.output_file.writelines( self.pre_index ) + + self.loaded_modules[ self._get_module_name( front = True ) ] = None + if self.include_reverse: + self.loaded_modules[ + self._get_module_name( front = False ) + ] = None + + # Write index: + for module_name in sorted( + self.loaded_modules.keys(), + key = str.lower + ): + self.output_file.write( module_name + "\n" ) + + # Write post-index: + self.output_file.writelines( self.post_index ) + + + #------------------------------------------------------------------------ + + def _write_preserved_modules( self, up_to = None ): + + if up_to is not None: + up_to = up_to.lower() + + for module_name in sorted( + self.loaded_modules.keys(), + key = str.lower + ): + if up_to is not None and module_name.lower() >= up_to: + continue + + module_lines = self.loaded_modules[ module_name ] + + if module_lines is not None: + + self.output_file.write( + "$MODULE {}\n".format( module_name ) + ) + self.output_file.writelines( module_lines ) + self.output_file.write( + "$EndMODULE {}\n".format( module_name ) + ) + + self.loaded_modules[ module_name ] = None + + + #------------------------------------------------------------------------ + + def _write_module_footer( self, front ): + + super( Svg2ModExportLegacyUpdater, self )._write_module_footer( + front, + ) + + # Write remaining modules: + if not front: + self._write_preserved_modules() + + + #------------------------------------------------------------------------ + + def _write_module_header( + self, + label_size, + label_pen, + reference_y, + value_y, + front, + ): + self._write_preserved_modules( + up_to = self._get_module_name( front ) + ) + + super( Svg2ModExportLegacyUpdater, self )._write_module_header( + label_size, + label_pen, + reference_y, + value_y, + front, + ) + + + #------------------------------------------------------------------------ + +#---------------------------------------------------------------------------- + +class Svg2ModExportPretty( Svg2ModExport ): + ''' This provides functionality for the + older kicad "pretty" footprint file formats. + It is a child of Svg2ModExport. + ''' + + layer_map = { + #'inkscape-name' : kicad-name, + 'F.Cu' : "F.Cu", + 'B.Cu' : "B.Cu", + 'F.Adhes' : "F.Adhes", + 'B.Adhes' : "B.Adhes", + 'F.Paste' : "F.Paste", + 'B.Paste' : "B.Paste", + 'F.SilkS' : "F.SilkS", + 'B.SilkS' : "B.SilkS", + 'F.Mask' : "F.Mask", + 'B.Mask' : "B.Mask", + 'Dwgs.User' : "Dwgs.User", + 'Cmts.User' : "Cmts.User", + 'Eco1.User' : "Eco1.User", + 'Eco2.User' : "Eco2.User", + 'Edge.Cuts' : "Edge.Cuts", + 'F.CrtYd' : "F.CrtYd", + 'B.CrtYd' : "B.CrtYd", + 'F.Fab' : "F.Fab", + 'B.Fab' : "B.Fab", + 'Drill.Cu': "Drill.Cu", + 'Drill.Mech': "Drill.Mech", + } + + keepout_allowed = ['tracks','vias','pads','copperpour','footprints'] + + + # Breaking changes where introduced in kicad v6 + # This variable disables the drill breaking changes for v5 support + _drill_inner_layers = False + + + #------------------------------------------------------------------------ + + def __init__(self, *args, **kwargs): + super(Svg2ModExportPretty, self).__init__(*args, **kwargs) + self._special_footer = "" + self._extra_indent = 0 + + #------------------------------------------------------------------------ + + def _get_layer_name( self, item_name, name, front ): + + name = self.layer_map[ name ] + + # For pretty format layers can have attributes in the svg name + # This validates all the layers and converts them to a json string + # which is attached to the returned value with `:` + attrs = {} + + # Keepout layer validation and expansion + if name == "Keepout": + attrs["layers"] = [] + layers = re.match(r'^(.*)\.Keepout', item_name[0]).groups()[0] + if len(layers) == 1 and layers not in "BFI*": + raise Exception("Unexpected keepout layer: {} in {}".format(layers, item_name[0])) + if len(layers) == 1: + if layers == '*': + attrs['layers'] = ['F','B','I'] + else: + attrs['layers'] = [layers] + else: + for layer in layers: + if layer not in "FBI": + raise Exception("Unexpected keepout layer: {} in {}".format(layer, item_name[0])) + if layer != '&': + attrs['layers'].append(layer) + + # All attributes with the exception of keepout layers is validated + if len(item_name) == 2 and item_name[1]: + for arg in item_name[1].split(';'): + arg = arg.strip(' ,:') + + # This is used in Svg2ModExportLatest as it is a breaking change + # Keepout allowed items + if name == "Keepout" and re.match(r'^allowed:\w+', arg, re.I): + attrs["allowed"] = [] + for allowed in arg.lower().split(":", 1)[1].split(','): + if allowed in self.keepout_allowed: + attrs["allowed"].append(allowed) + else: + logger.warning("Invalid allowed option in keepout: {} in {}".format(allowed, arg)) + # Zone hatch patterns + elif name == "Keepout" and re.match(r'^hatch:(none|edge|full)$', arg, re.I): + attrs["hatch"] = arg.split(":", 1)[1] + + #Copper pad attributes + elif re.match(r'^\w+\.Cu', name) and re.match(r'^pad(:(\d+|mask|paste))?', arg, re.I): + if arg.lower() == "pad": + attrs["copper_pad"] = True + else: + ops = arg.split(":", 1)[1] + for opt in ops.split(','): + if re.match(r'^\d+$', opt): + attrs["copper_pad"] = int(opt) + elif opt.lower() == "mask" and name != "Drill.Cu": + attrs["pad_mask"] = True + if not attrs.get("copper_pad"): + attrs["copper_pad"] = True + elif opt.lower() == "paste" and name != "Drill.Cu": + attrs["pad_paste"] = True + if not attrs.get("copper_pad"): + attrs["copper_pad"] = True + else: + logger.warning("Invalid pad option '{}' for layer {}".format(opt, name)) + else: + logger.warning("Unexpected option: {} for {}".format(arg, item_name[0])) + if attrs: + return name+":"+json.dumps(attrs) + return name + + + #------------------------------------------------------------------------ + + def _get_module_name( self, front = None ): + + return self.imported.module_name + + + #------------------------------------------------------------------------ + + def _write_library_intro( self, cmdline ): + + self.output_file.write( """(module {0} (layer F.Cu) (tedit {1:8X}) + (attr virtual) + (descr "{2}") + (tags {3}) +""".format( + self.imported.module_name, #0 + int( round( #1 + os.path.getctime( self.imported.file_name ) if self.imported.file_name else time.time() + ) ), + "Converted using: {}".format( cmdline.replace("\\", "\\\\") ), #2 + "svg2mod", #3 + ) + ) + + + #------------------------------------------------------------------------ + + def _write_module_footer( self, front ): + + self.output_file.write( "\n)" ) + + + #------------------------------------------------------------------------ + + def _write_module_header( + self, label_size, label_pen, + reference_y, value_y, front, + ): + if front: + side = "F" + else: + side = "B" + + self.output_file.write( +""" (fp_text reference {0} (at 0 {1}) (layer {2}.SilkS) hide + (effects (font (size {3} {3}) (thickness {4}))) + ) + (fp_text value {5} (at 0 {6}) (layer {2}.SilkS) hide + (effects (font (size {3} {3}) (thickness {4}))) + )""".format( + + self._get_module_name(), #0 + reference_y, #1 + side, #2 + label_size, #3 + label_pen, #4 + self.imported.module_value, #5 + value_y, #6 + ) + ) + + + #------------------------------------------------------------------------ + + def _write_modules( self ): + + self._write_module( front = True ) + + + #------------------------------------------------------------------------ + + def _write_polygon_filled( self, points, layer, stroke_width = 0): + self._write_polygon_header( points, layer, stroke_width) + + for point in points: + self._write_polygon_point( point ) + + self._write_polygon_footer( layer, stroke_width ) + + #------------------------------------------------------------------------ + + def _write_polygon_footer( self, layer, stroke_width, fill=True ): + + #Format option #2 is expected, but only used in Svg2ModExportLatest + if self._special_footer: + self.output_file.write(self._special_footer.format( + layer.split(":", 1)[0], stroke_width, "" #2 + )) + else: + self.output_file.write( + " )\n (layer {})\n (width {}){}\n )".format( + layer.split(":", 1)[0], stroke_width, "" #3 + ) + ) + self._special_footer = "" + self._extra_indent = 0 + + + #------------------------------------------------------------------------ + + def _write_polygon_header( self, points, layer, stroke_width): + + l_name = layer + options = {} + try: + l_name, options = layer.split(":", 1) + options = json.loads(options) + except ValueError:pass + + create_pad = (self.convert_pads and l_name.find("Cu") == 2) or options.get("copper_pad") + + if stroke_width == 0: + stroke_width = MINIMUM_SIZE + + if l_name == "Keepout": + self._extra_indent = 1 + layers = ["*"] + if len(options["layers"]) == 3: + layers = ["*"] + elif "I" not in options["layers"]: + layers = ["&".join(options["layers"])] + else: + options["layers"].remove("I") + layers = options["layers"][:] + ['In{}'.format(i) for i in range(1,31)] + + + self.output_file.write( '''\n (zone (net 0) (net_name "") (layers "{0}.Cu") (hatch {1} {2:.6f}) + (connect_pads (clearance 0)) + (min_thickness {3:.6f}) + (keepout ({4}allowed)) + (fill (thermal_gap {2:.6f}) (thermal_bridge_width {2:.6f})) + (polygon + (pts\n'''.format( + '.Cu" "'.join(layers), #0 + options["hatch"] if options.get("hatch") else "full", #1 + stroke_width, #2 + stroke_width/2, #3 + "allowed) (".join( + [i+" "+( + "not_" if not options.get("allowed") or i not in options["allowed"] else "" + ) for i in self.keepout_allowed] + ), #4 + ) + ) + self._special_footer = " )\n )\n )" + elif create_pad: + pad_number = "" if not options.get("copper_pad") or isinstance(options["copper_pad"], bool) else str(options.get("copper_pad")) + layer = l_name + if options.get("pad_mask"): + layer += " {}.Mask".format(l_name.split(".", 1)[0]) + if options.get("pad_paste"): + layer += " {}.Paste".format(l_name.split(".", 1)[0]) + self._extra_indent = 1 + + self._special_footer = "\n )" + + self.output_file.write( '''\n (pad "{0}" smd custom (at {1} {2}) (size {3:.6f} {3:.6f}) (layers {4}) + (zone_connect 0) + (options (clearance outline) (anchor circle))'''.format( + pad_number, #0 + points[0].x, #1 + points[0].y, #2 + stroke_width, #3 + layer, #4 + ) + ) + # Pads primitives with 2 or less points crash kicad + if len(points) >= 2: + self.output_file.write('''\n (primitives\n (gr_poly (pts \n''') + self._special_footer = " )\n (width {}){{2}})\n ))".format(stroke_width) + + origin_x = points[0].x + origin_y = points[0].y + for point in points: + point.x = point.x-origin_x + point.y = point.y-origin_y + else: + for point in points[:]: + points.remove(point) + else: + self.output_file.write( "\n (fp_poly\n (pts \n" ) + + + #------------------------------------------------------------------------ + + def _write_polygon_point( self, point ): + + if self._extra_indent: + self.output_file.write(" "*self._extra_indent) + + self.output_file.write( + " (xy {} {})\n".format( point.x, point.y ) + ) + + + #------------------------------------------------------------------------ + + def _write_polygon_segment( self, p, q, layer, stroke_width ): + l_name = layer + options = {} + try: + l_name, options = layer.split(":", 1) + options = json.loads(options) + except ValueError:pass + + create_pad = (self.convert_pads and l_name.find("Cu") == 2) or options.get("copper_pad") + + if stroke_width == 0: + stroke_width = MINIMUM_SIZE + + if create_pad: + pad_number = "" if not options.get("copper_pad") or isinstance(options["copper_pad"], bool) else str(options.get("copper_pad")) + layer = l_name + if options.get("pad_mask"): + layer += " {}.Mask".format(l_name.split(".", 1)[0]) + if options.get("pad_paste"): + layer += " {}.Paste".format(l_name.split(".", 1)[0]) + + # There are major performance issues when multiple line primitives are in the same pad + self.output_file.write( '''\n (pad "{0}" smd custom (at {1} {2}) (size {3:.6f} {3:.6f}) (layers {4}) + (zone_connect 0) + (options (clearance outline) (anchor circle)) + (primitives\n (gr_line (start 0 0) (end {5} {6}) (width {3})) + ))'''.format( + pad_number, #0 + p.x, #1 + p.y, #2 + stroke_width, #3 + layer, #4 + q.x - p.x, #5 + q.y - p.y, #6 + ) + ) + else: + + self.output_file.write( + """\n (fp_line + (start {} {}) (end {} {}) + (layer {}) (width {}) + )""".format( + p.x, p.y, + q.x, q.y, + layer.split(':',1)[0], + stroke_width, + ) + ) + + #------------------------------------------------------------------------ + + def _write_thru_hole( self, circle, layer ): + + if not isinstance(circle, svg.Circle): + logger.info("Found an ellipse in Drill layer. Using an average of rx and ry.") + circle.rx = (circle.rx + circle.ry ) / 2 + + l_name = layer + options = {} + try: + l_name, options = layer.split(":", 1) + options = json.loads(options) + except ValueError:pass + + plated = l_name == "Drill.Cu" + pad_number = "" + if plated and options.get("copper_pad") and not isinstance(options["copper_pad"], bool): + pad_number = str(options.get("copper_pad")) + + rad = circle.rx * self.scale_factor + drill = rad * 2 + + size = circle.style.get("stroke-width") * self.scale_factor + if size and plated: + drill -= size + size = size + ( rad * 2 ) + elif size: + size = size + ( rad * 2 ) + drill = size + else: + size = rad + + center = self.transform_point(circle.center) + + self.output_file.write( + '\n (pad "{0}" {1}thru_hole circle (at {2} {3}) (size {4} {4}) (drill {5}) (layers *.Mask{6}) {7})'.format( + pad_number, #0 + "" if plated else "np_", #1 + center.x, #2 + center.y, #3 + size, #4 + drill, #5 + " *.Cu" if plated else "", #6 + "(remove_unused_layers) (keep_end_layers)" if plated and self._drill_inner_layers else "", #7 + ) + ) + + #------------------------------------------------------------------------ + +#---------------------------------------------------------------------------- + + +class Svg2ModExportLatest(Svg2ModExportPretty): + ''' This provides functionality for the newer kicad + "pretty" footprint file formats introduced in kicad v6. + It is a child of Svg2ModExport. + ''' + + layer_map = { + #'inkscape-name' : kicad-name, + 'F.Cu' : "F.Cu", + 'B.Cu' : "B.Cu", + 'F.Adhes' : "F.Adhes", + 'B.Adhes' : "B.Adhes", + 'F.Paste' : "F.Paste", + 'B.Paste' : "B.Paste", + 'F.SilkS' : "F.SilkS", + 'B.SilkS' : "B.SilkS", + 'F.Mask' : "F.Mask", + 'B.Mask' : "B.Mask", + 'Dwgs.User' : "Dwgs.User", + 'Cmts.User' : "Cmts.User", + 'Eco1.User' : "Eco1.User", + 'Eco2.User' : "Eco2.User", + 'Edge.Cuts' : "Edge.Cuts", + 'F.CrtYd' : "F.CrtYd", + 'B.CrtYd' : "B.CrtYd", + 'F.Fab' : "F.Fab", + 'B.Fab' : "B.Fab", + 'Drill.Cu': "Drill.Cu", + 'Drill.Mech': "Drill.Mech", + r'\S+\.Keepout': "Keepout" + } + + # Breaking changes where introduced in kicad v6 + # This variable enables the drill breaking changes for v5 support + _drill_inner_layers = True + + #------------------------------------------------------------------------ + + def _write_polygon_outline( self, points, layer, stroke_width = 0): + self._write_polygon_header( points, layer, stroke_width) + + for point in points: + self._write_polygon_point( point ) + + self._write_polygon_footer( layer, stroke_width, fill=False ) + + #------------------------------------------------------------------------ + + def _write_polygon_footer( self, layer, stroke_width, fill=True ): + + if self._special_footer: + self.output_file.write(self._special_footer.format( + layer.split(":", 1)[0], stroke_width, + " (fill none)" if not fill else "" + )) + else: + self.output_file.write( + " )\n (layer {})\n (width {}){}\n )".format( + layer.split(":", 1)[0], stroke_width, + " (fill none)" if not fill else "" + ) + ) + self._special_footer = "" + self._extra_indent = 0 + + + + #------------------------------------------------------------------------ + +#---------------------------------------------------------------------------- diff --git a/svg2mod/importer.py b/svg2mod/importer.py new file mode 100644 index 0000000..d641dca --- /dev/null +++ b/svg2mod/importer.py @@ -0,0 +1,77 @@ +# Copyright (C) 2021 -- svg2mod developers < GitHub.com / svg2mod > + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +''' +Svg2ModImport is responsible for basic parsing +of svg layers to be used with an instance of +Svg2ModExport. +''' + + +from svg2mod import svg +from svg2mod.coloredlogger import logger, unfiltered_logger + +#---------------------------------------------------------------------------- + +class Svg2ModImport: + ''' An importer class to read in target svg, + parse it, and keep only layers on interest. + ''' + + #------------------------------------------------------------------------ + + def _prune_hidden( self, items = None ): + + if items is None: + + items = self.svg.items + + for item in items[:]: + + if hasattr(item, "hidden") and item.hidden: + if hasattr(item, "name") and item.name: + logger.warning("Ignoring hidden SVG item: {}".format( item.name ) ) + items.remove(item) + + if hasattr(item, "items") and item.items: + self._prune_hidden( item.items ) + + #------------------------------------------------------------------------ + + def __init__( self, file_name=None, module_name="svg2mod", module_value="G***", ignore_hidden=False, force_layer=None): + + self.file_name = file_name + self.module_name = module_name + self.module_value = module_value + self.ignore_hidden = ignore_hidden + + if file_name: + unfiltered_logger.info( "Parsing SVG..." ) + + self.svg = svg.parse( file_name ) + logger.info("Document scaling: {} units per pixel".format(self.svg.viewport_scale)) + if force_layer: + new_layer = svg.Group() + new_layer.name = force_layer + new_layer.items = self.svg.items[:] + self.svg.items = [new_layer] + if self.ignore_hidden: + self._prune_hidden() + + + #------------------------------------------------------------------------ + +#---------------------------------------------------------------------------- diff --git a/svg2mod/svg/LICENSE b/svg2mod/svg/LICENSE deleted file mode 100644 index d159169..0000000 --- a/svg2mod/svg/LICENSE +++ /dev/null @@ -1,339 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. diff --git a/svg2mod/svg/README.md b/svg2mod/svg/README.md deleted file mode 100644 index 6ab06cd..0000000 --- a/svg2mod/svg/README.md +++ /dev/null @@ -1,18 +0,0 @@ -SVG parser library -================== - -This is a SVG parser library written in Python. -([see here](https://github.com/cjlano/svg])) - -Capabilities: - - Parse SVG XML - - apply any transformation (svg transform) - - Explode SVG Path into basic elements (Line, Bezier, ...) - - Interpolate SVG Path as a series of segments - - Able to simplify segments given a precision using Ramer-Douglas-Peucker algorithm - -Not (yet) supported: - - SVG Path Arc ('A') - - Non-linear transformation drawing (SkewX, ...) - -License: GPLv2+ diff --git a/svg2mod/svg/__init__.py b/svg2mod/svg/__init__.py index b3c8618..6d6bf0c 100644 --- a/svg2mod/svg/__init__.py +++ b/svg2mod/svg/__init__.py @@ -1 +1,11 @@ +''' +A SVG parser with tools to convert an XML svg file +to objects that can be simplified into points. +''' +#__all__ = ['geometry', 'svg'] + from .svg import * + +def parse(filename): + '''Take in a filename and return a SVG object of parsed file''' + return Svg(filename) diff --git a/svg2mod/svg/svg/geometry.py b/svg2mod/svg/geometry.py similarity index 80% rename from svg2mod/svg/svg/geometry.py rename to svg2mod/svg/geometry.py index 7a4114f..a931281 100644 --- a/svg2mod/svg/svg/geometry.py +++ b/svg2mod/svg/geometry.py @@ -1,4 +1,5 @@ # Copyright (C) 2013 -- CJlano < cjlano @ free.fr > +# Copyright (C) 2022 -- svg2mod developers < GitHub.com / svg2mod > # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,6 +25,7 @@ import operator class Point: + '''Define a point as two floats accessible by x and y''' def __init__(self, x=None, y=None): '''A Point is defined either by a tuple/list of length 2 or by 2 coordinates @@ -64,7 +66,7 @@ def __add__(self, other): return Point(self.x + other.x, self.y + other.y) def __sub__(self, other): - '''Substract two Points. + '''Subtract two Points. >>> Point(1,2) - Point(3,2) (-2.000,0.000) ''' @@ -104,7 +106,7 @@ def __repr__(self): return '(' + format(self.x,'.3f') + ',' + format( self.y,'.3f') + ')' def __str__(self): - return self.__repr__(); + return self.__repr__() def coord(self): '''Return the point tuple (x,y)''' @@ -114,14 +116,21 @@ def length(self): '''Vector length, Pythagoras theorem''' return math.sqrt(self.x ** 2 + self.y ** 2) - def rot(self, angle): + def rot(self, angle, x=0, y=0): '''Rotate vector [Origin,self] ''' if not isinstance(angle, Angle): try: angle = Angle(angle) except: return NotImplemented - x = self.x * angle.cos - self.y * angle.sin - y = self.x * angle.sin + self.y * angle.cos - return Point(x,y) + if angle.angle % (2 * math.pi) == 0: + return Point(self.x,self.y) + + new_x = ((self.x-x) * angle.cos) - ((self.y-y) * angle.sin) + x + new_y = ((self.x-x) * angle.sin) + ((self.y-y) * angle.cos) + y + return Point(new_x,new_y) + + def round(self, num_digits=None): + '''Round x and y to number of decimal points''' + return Point( round(self.x, num_digits), round(self.y, num_digits)) class Angle: @@ -150,6 +159,11 @@ def __init__(self, arg): def __neg__(self): return Angle(Point(self.cos, -self.sin)) + def __add__(self, other): + if not isinstance(other, Angle): + try: other = Angle(other) + except: return NotImplemented + return Angle(self.angle+other.angle) class Segment: '''A segment is an object defined by 2 points''' @@ -160,7 +174,7 @@ def __init__(self, start, end): def __str__(self): return 'Segment from ' + str(self.start) + ' to ' + str(self.end) - def segments(self, precision=0): + def segments(self, __=0): ''' Segments is simply the segment start -> end''' return [self.start, self.end] @@ -182,15 +196,15 @@ def pdistance(self, p): if s.x == 0: # Vertical Segment => pdistance is the difference of abscissa return abs(self.start.x - p.x) - else: - # That's 2-D perpendicular distance formulae (ref: Wikipedia) - slope = s.y/s.x - # intercept: Crossing with ordinate y-axis - intercept = self.start.y - (slope * self.start.x) - return abs(slope * p.x - p.y + intercept) / math.sqrt(slope ** 2 + 1) + # That's 2-D perpendicular distance formula (ref: Wikipedia) + slope = s.y/s.x + # intercept: Crossing with ordinate y-axis + intercept = self.start.y - (slope * self.start.x) + return abs(slope * p.x - p.y + intercept) / math.sqrt(slope ** 2 + 1) def bbox(self): + '''Return bounding box as ( Point(min), Point(max )''' xmin = min(self.start.x, self.end.x) xmax = max(self.start.x, self.end.x) ymin = min(self.start.y, self.end.y) @@ -199,19 +213,10 @@ def bbox(self): return (Point(xmin,ymin),Point(xmax,ymax)) def transform(self, matrix): + '''Transform start and end point by provided matrix''' self.start = matrix * self.start self.end = matrix * self.end - def scale(self, ratio): - self.start *= ratio - self.end *= ratio - def translate(self, offset): - self.start += offset - self.end += offset - def rotate(self, angle): - self.start = self.start.rot(angle) - self.end = self.end.rot(angle) - class Bezier: '''Bezier curve class A Bezier curve is defined by its control points @@ -227,12 +232,12 @@ def __str__(self): ' : ' + ", ".join([str(x) for x in self.pts]) def control_point(self, n): + '''Return Point at index n''' if n >= self.dimension: raise LookupError('Index is larger than Bezier curve dimension') - else: - return self.pts[n] + return self.pts[n] - def rlength(self): + def r_length(self): '''Rough Bezier length: length of control point segments''' pts = list(self.pts) l = 0.0 @@ -244,9 +249,10 @@ def rlength(self): return l def bbox(self): - return self.rbbox() + '''This returns the rough bounding box ''' + return self.r_bbox() - def rbbox(self): + def r_bbox(self): '''Rough bounding box: return the bounding box (P1,P2) of the Bezier _control_ points''' xmin = min([p.x for p in self.pts]) @@ -257,12 +263,12 @@ def rbbox(self): return (Point(xmin,ymin), Point(xmax,ymax)) def segments(self, precision=0): - '''Return a polyline approximation ("segments") of the Bezier curve - precision is the minimum significative length of a segment''' + '''Return a poly-line approximation ("segments") of the Bezier curve + precision is the minimum significant length of a segment''' segments = [] # n is the number of Bezier points to draw according to precision if precision != 0: - n = int(self.rlength() / precision) + 1 + n = int(self.r_length() / precision) + 1 else: n = 1000 #if n < 10: n = 10 @@ -272,7 +278,8 @@ def segments(self, precision=0): segments.append(self._bezierN(float(t)/n)) return segments - def _bezier1(self, p0, p1, t): + @staticmethod + def _bezier1(p0, p1, t): '''Bezier curve, one dimension Compute the Point corresponding to a linear Bezier curve between p0 and p1 at "time" t ''' @@ -291,36 +298,29 @@ def _bezierN(self, t): # For each control point of nth dimension, # compute linear Bezier point a t for i in range(0,n-1): - res[i] = self._bezier1(res[i], res[i+1], t) + res[i] = Bezier._bezier1(res[i], res[i+1], t) return res[0] def transform(self, matrix): + '''Transform every point by the provided matrix''' self.pts = [matrix * x for x in self.pts] - def scale(self, ratio): - self.pts = [x * ratio for x in self.pts] - def translate(self, offset): - self.pts = [x + offset for x in self.pts] - def rotate(self, angle): - self.pts = [x.rot(angle) for x in self.pts] - class MoveTo: + '''MoveTo class + This will create a move without creating a segment + to the destination point. + ''' def __init__(self, dest): self.dest = dest def bbox(self): + '''This returns a single point bounding box. ( Point(destination), Point(destination) )''' return (self.dest, self.dest) def transform(self, matrix): + '''Transform the destination point by provided matrix''' self.dest = matrix * self.dest - def scale(self, ratio): - self.dest *= ratio - def translate(self, offset): - self.dest += offset - def rotate(self, angle): - self.dest = self.dest.rot(angle) - def simplify_segment(segment, epsilon): '''Ramer-Douglas-Peucker algorithm''' @@ -334,10 +334,9 @@ def simplify_segment(segment, epsilon): key=operator.itemgetter(1)) if maxDist > epsilon: - # Recursively call with segment splited in 2 on its furthest point + # Recursively call with segment split in 2 on its furthest point r1 = simplify_segment(segment[:index+1], epsilon) r2 = simplify_segment(segment[index:], epsilon) # Remove redundant 'middle' Point return r1[:-1] + r2 - else: - return [segment[0], segment[-1]] + return [segment[0], segment[-1]] diff --git a/svg2mod/svg/svg.py b/svg2mod/svg/svg.py new file mode 100644 index 0000000..baa4d43 --- /dev/null +++ b/svg2mod/svg/svg.py @@ -0,0 +1,1491 @@ +# Copyright (C) 2013 -- CJlano < cjlano @ free.fr > +# Copyright (C) 2022 -- svg2mod developers < GitHub.com / svg2mod > + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +''' +A SVG parser with tools to convert an XML svg file +to objects that can be simplified into points. +''' + +import copy +import inspect +import itertools +import json +import logging +import math +import operator +import os +import platform +import re +import sys +import xml.etree.ElementTree as etree +from typing import Iterable, List, Tuple + +from fontTools.misc import loggingTools +from fontTools.pens.svgPathPen import SVGPathPen +from fontTools.ttLib import ttFont + +from svg2mod.coloredlogger import logger + +from .geometry import Angle, Bezier, MoveTo, Point, Segment, simplify_segment + +svg_ns = '{http://www.w3.org/2000/svg}' + +# Regex commonly used +number_re = r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?' +unit_re = r'em|ex|px|in|cm|mm|pt|pc|%' + +# styles of interest and their defaults +svg_defaults = { + "fill":"black", + "fill-opacity":"1", + "stroke":"none", + "stroke-width":"1px", + "stroke-opacity":"1", + } + +# Unit converter +unit_convert = { + None: 1, # Default unit (same as pixel) + 'px': 1, # px: pixel. Default SVG unit + 'em': 10, # 1 em = 10 px FIXME + 'ex': 5, # 1 ex = 5 px FIXME + 'in': 96, # 1 in = 96 px + 'cm': 96 / 2.54, # 1 cm = 1/2.54 in + 'mm': 96 / 25.4, # 1 mm = 1/25.4 in + 'pt': 96 / 72.0, # 1 pt = 1/72 in + 'pc': 96 / 6.0, # 1 pc = 1/6 in + '%' : 1 / 100.0 # 1 percent + } + +# Logging spammed 'Unable to find font because no font was specified.' +# this allows it to only print the error once before muting it for that run. +_font_warning_sent = False + + +class Transformable: + '''Abstract class for objects that can be geometrically drawn & transformed''' + + # This list is all styles that should have the transformation matrix applied + transformable_styles = ["stroke-width"] + + def __init__(self, elt=None, parent_styles=None): + # a 'Transformable' is represented as a list of Transformable items + self.items = [] + self.id = hex(id(self)) + self.name = "" + self.fill_even_odd = False + # Unit transformation matrix on init + self.matrix = Matrix() + self.xscale = 1 + self.yscale = 1 + self.style = svg_defaults.copy() if not parent_styles and not isinstance(parent_styles, dict) else parent_styles.copy() + self.rotation = 0 + self.viewport = Point(800, 600) # default viewport is 800x600 + if elt is not None: + self.id = elt.get('id', self.id) + + # get inkscape:label as self.name + for ident, value in elt.attrib.items(): + + ident = self.parse_name( ident ) + if ident[ "name" ] == "label": + self.name = value + break + # self.name isn't set so try setting name to id + if self.name == '': + self.name == self.id + + # set fill_even_odd if property set + self.fill_even_odd = elt.get("fill-rule", '').lower() == 'evenodd' + if self.fill_even_odd: + logger.warning(f"Found unsupported attribute: 'fill-rule=evenodd' for {repr(self)}") + + # Find attributes of interest. The are overwritten by styles + for style_key in svg_defaults: + self.style[style_key] = elt.get(style_key, self.style[style_key]) + + # parse styles and save as dictionary. + if elt.get('style'): + for style in elt.get('style').split(";"): + if style.find(":") == -1: + self.style[name] = None + else: + nv = style.split(":") + name = nv[ 0 ].strip() + value = nv[ 1 ].strip() + if name in self.transformable_styles: + value = list(re.search(r'(\d+\.?\d*)(\D+)?', value).groups()) + self.style[name] = float(value[0]) + if value[1] and value[1] not in unit_convert: + logger.warning("Style '{}' has an unexpected unit: {}".format(style, value[1])) + else: + self.style[name] = value + + # Parse transform attribute to update self.matrix + self.get_transformations(elt) + + if self.style.get("display") == "none": + self.hidden = True + + @staticmethod + def parse_name( tag ): + '''Read and return name from xml data''' + m = re.match( r'({(.+)})?(.+)', tag ) + return { + 'namespace' : m.group( 2 ), + 'name' : m.group( 3 ), + } + + def bbox(self): + '''Bounding box of all points''' + b_boxes = [x.bbox() for x in self.items] + if len( b_boxes ) < 1: + return (Point(0, 0), Point(0, 0)) + xmin = min([b[0].x for b in b_boxes]) + xmax = max([b[1].x for b in b_boxes]) + ymin = min([b[0].y for b in b_boxes]) + ymax = max([b[1].y for b in b_boxes]) + + return (Point(xmin,ymin), Point(xmax,ymax)) + + # Parse transform field + def get_transformations(self, elt): + '''Take an xml element and parse transformation commands + then apply the matrix and set any needed variables + ''' + t = elt.get('transform') + if t is None: return + + svg_transforms = [ + 'matrix', 'translate', 'scale', 'rotate', 'skewX', 'skewY'] + + # match any SVG transformation with its parameter (until final parenthesis) + # [^)]* == anything but a closing parenthesis + # '|'.join == OR-list of SVG transformations + transforms = re.findall( + '|'.join([x + r'[^)]*\)' for x in svg_transforms]), t) + + for t in transforms: + op, arg = t.split('(') + op = op.strip() + # Keep only numbers + arg = [float(x) for x in re.findall(number_re, arg)] + logger.debug('transform: ' + op + ' '+ str(arg)) + + if op == 'matrix': + self.matrix *= Matrix(arg) + + if op == 'translate': + tx = arg[0] + if len(arg) == 1: ty = 0 + else: ty = arg[1] + self.matrix *= Matrix([1, 0, 0, 1, tx, ty]) + + if op == 'scale': + sx = arg[0] + if len(arg) == 1: sy = sx + else: sy = arg[1] + self.xscale *= sx + self.yscale *= sy + self.matrix *= Matrix([sx, 0, 0, sy, 0, 0]) + + if op == 'rotate': + self.rotation += arg[0] + cos_a = math.cos(math.radians(arg[0])) + sin_a = math.sin(math.radians(arg[0])) + if len(arg) != 1: + tx, ty = arg[1:3] + self.matrix *= Matrix([1, 0, 0, 1, tx, ty]) + self.matrix *= Matrix([cos_a, sin_a, -sin_a, cos_a, 0, 0]) + if len(arg) != 1: + self.matrix *= Matrix([1, 0, 0, 1, -tx, -ty]) + + if op == 'skewX': + tana = math.tan(math.radians(arg[0])) + self.matrix *= Matrix([1, 0, tana, 1, 0, 0]) + + if op == 'skewY': + tana = math.tan(math.radians(arg[0])) + self.matrix *= Matrix([1, tana, 0, 1, 0, 0]) + + def transform_styles(self, matrix): + '''Any style in this classes transformable_styles + will be scaled by the provided matrix. + If it has a unit type it will convert it to the proper value first. + ''' + for style in self.transformable_styles: + if self.style.get(style): + has_units = re.search(r'\D+', self.style[style] if isinstance(self.style[style], str) else '') + xscale = abs(matrix.xscale()) + yscale = abs(matrix.yscale()) + if has_units is None: + self.style[style] = float(self.style[style]) * ((xscale+yscale)/2) + else: + unit = has_units.group().lower() + self.style[style] = float(re.search(r'\d+', self.style[style]).group()) * unit_convert.get(unit, 1) * ((xscale+yscale)/2) + + + def transform(self, matrix=None): + '''Apply the provided matrix. Default (None) + If no matrix is supplied then recursively apply + it's already existing matrix to all items. + ''' + if matrix is None: + matrix = self.matrix + else: + matrix *= self.matrix + self.transform_styles(matrix) + for x in self.items: + x.transform(matrix) + + def length(self, v, mode='xy'): + '''Return generic 2 dimensional length of svg element''' + # Handle empty (non-existing) length element + if v is None: + return 0 + + # Get length value + m = re.search(number_re, v) + if m: value = m.group(0) + else: raise TypeError(v + 'is not a valid length') + + # Get length unit + m = re.search(unit_re, v) + if m: unit = m.group(0) + else: unit = None + + if unit == '%': + if mode == 'x': + return float(value) * unit_convert[unit] * self.viewport.x + if mode == 'y': + return float(value) * unit_convert[unit] * self.viewport.y + if mode == 'xy': + return float(value) * unit_convert[unit] * self.viewport.x # FIXME + + return float(value) * unit_convert[unit] + + def xlength(self, x): + '''Length of element's x component''' + return self.length(x, 'x') + def ylength(self, y): + '''Length of element's y component''' + return self.length(y, 'y') + + def flatten(self): + '''Flatten the SVG objects nested list into a flat (1-D) list, + removing Groups''' + # http://rightfootin.blogspot.fr/2006/09/more-on-python-flatten.html + # Assigning a slice a[i:i+1] with a list actually replaces the a[i] + # element with the content of the assigned list + i = 0 + flat = copy.deepcopy(self.items) + while i < len(flat): + while isinstance(flat[i], Group): + flat[i:i+1] = flat[i].items + i += 1 + return flat + +class Svg(Transformable): + '''SVG class: use parse to parse a file''' + # class Svg handles the tag + # tag = 'svg' + + def __init__(self, filename=None): + self.viewport_scale = 1 + Transformable.__init__(self) + if filename: + self.parse(filename) + + def parse(self, filename:str): + '''Read provided svg xml file and + append all svg element to items list + ''' + self.filename = filename + tree = etree.parse(filename) + self.root = tree.getroot() + if self.root.tag != svg_ns + 'svg': + raise TypeError('file %s does not seem to be a valid SVG file', filename) + + # Create a top Group to group all other items (useful for viewBox elt) + top_group = Group() + self.items.append(top_group) + + # SVG dimension + width = self.xlength(self.root.get('width')) + height = self.ylength(self.root.get('height')) + + # update viewport + top_group.viewport = Point(width, height) + + # viewBox + if self.root.get('viewBox') is not None: + view_box = re.findall(number_re, self.root.get('viewBox')) + + # If the document somehow doesn't have dimensions get if from viewBox + if self.root.get('width') is None or self.root.get('height') is None: + width = float(view_box[2]) - float(view_box[0]) + height = float(view_box[3]) - float(view_box[1]) + logger.debug("Unable to find width or height properties. Using viewBox.") + + sx = width / (float(view_box[2]) - float(view_box[0])) + sy = height / (float(view_box[3]) - float(view_box[1])) + tx = -float(view_box[0]) + ty = -float(view_box[1]) + self.viewport_scale = round((float(view_box[2]) - float(view_box[0]))/width, 6) + top_group.matrix = Matrix([sx, 0, 0, sy, tx, ty]) + if ( self.root.get("width") is None or self.root.get("height") is None ) \ + and self.root.get("viewBox") is None: + logger.critical("Fatal Error: Unable to find SVG dimensions. Exiting.") + sys.exit(-1) + + # Parse XML elements hierarchically with groups + top_group.append(self.root) + + self.transform() + + def title(self): + '''Returns svg title if exists. Otherwise try to return filename''' + t = self.root.find(svg_ns + 'title') + if t is not None: + return t + return os.path.splitext(os.path.basename(self.filename))[0] + + def json(self): + '''Return a dictionary of children items''' + return self.items + + +class Group(Transformable): + '''Handle svg elements + The name and hidden attributes are stored in self.name + and self.hidden respectively. These can be manually set + if object is not initialized with an xml element. + ''' + # class Group handles the tag + tag = 'g' + + def __init__(self, elt=None, *args, **kwargs): + Transformable.__init__(self, elt, *args, **kwargs) + + def append(self, element): + '''Convert and append xml element(s) to items list + element is expected to be iterable. + If an svg non xml object needs to be appended + then interface directly with the items list: + group.items.append(svg_object) + ''' + for elt in element: + elt_class = svgClass.get(elt.tag, None) + if elt_class is None: + logger.debug('No handler for element %s' % elt.tag) + continue + # instantiate elt associated class (e.g. : item = Path(elt) + item = elt_class(elt, parent_styles=self.style) + # Apply group matrix to the newly created object + # Actually, this is effectively done in Svg.__init__() through call to + # self.transform(), so doing it here will result in the transformations + # being applied twice. + #item.matrix = self.matrix * item.matrix + item.viewport = self.viewport + + self.items.append(item) + # Recursively append if elt is a (group) + if elt.tag == svg_ns + 'g': + item.append(elt) + + def __repr__(self): + return ': ' + repr(self.items) + + def json(self): + '''Return json formatted dictionary of group''' + return {'Group ' + self.id + " ({})".format( self.name ) : self.items} + +class Matrix: + '''SVG transformation matrix and its operations + a SVG matrix is represented as a list of 6 values [a, b, c, d, e, f] + (named vect hereafter) which represent the 3x3 matrix + ((a, c, e) + (b, d, f) + (0, 0, 1)) + SVGs implement the same transform that CSS does + see https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix#values''' + + def __init__(self, vect=None): + # Unit transformation vect by default + if vect is None: + vect = [1, 0, 0, 1, 0, 0] + if len(vect) != 6: + raise ValueError("Bad vect size %d" % len(vect)) + self.vect = list(vect) + + def __mul__(self, other): + '''Matrix multiplication''' + if isinstance(other, Matrix): + a = self.vect[0] * other.vect[0] + self.vect[2] * other.vect[1] + b = self.vect[1] * other.vect[0] + self.vect[3] * other.vect[1] + c = self.vect[0] * other.vect[2] + self.vect[2] * other.vect[3] + d = self.vect[1] * other.vect[2] + self.vect[3] * other.vect[3] + e = self.vect[0] * other.vect[4] + self.vect[2] * other.vect[5] \ + + self.vect[4] + f = self.vect[1] * other.vect[4] + self.vect[3] * other.vect[5] \ + + self.vect[5] + return Matrix([a, b, c, d, e, f]) + + if isinstance(other, Point): + x = other.x * self.vect[0] + other.y * self.vect[2] + self.vect[4] + y = other.x * self.vect[1] + other.y * self.vect[3] + self.vect[5] + return Point(x,y) + + return NotImplemented + + def __str__(self): + return str(self.vect) + + def xscale(self): + '''Return the rotated x scalar value''' + if self.vect[0] == 0: + return abs(self.vect[2]) + else: + return self.vect[0]/abs(self.vect[0]) * math.sqrt(self.vect[0]**2 + self.vect[2]**2) + def yscale(self): + '''Return the rotated y scalar value''' + if self.vect[3] == 0: + return abs(self.vect[1]) + else: + return self.vect[3]/abs(self.vect[3]) * math.sqrt(self.vect[1]**2 + self.vect[3]**2) + def rot(self): + '''Return the angle of rotation from the matrix. + + https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix + ''' + if self.vect[0] != 0: + return Angle(math.atan2(-self.vect[2], self.vect[0])) + if self.vect[3] != 0: + return Angle(math.atan2(self.vect[1], self.vect[3])) + return 0 + + +class Path(Transformable): + '''SVG tag handler + self.items contains all objects for path instructions. + Calling .parse(...) will append new path instruction + objects to items list. + ''' + # class Path handles the tag + tag = 'path' + COMMANDS = 'MmZzLlHhVvCcSsQqTtAa' + + def __init__(self, elt=None, *args, **kwargs): + Transformable.__init__(self, elt, *args, **kwargs) + if elt is not None: + self.parse(elt.get('d')) + + def parse(self, path_str:str): + """Parse svg path string and build elements list""" + + path_list = re.findall(number_re + r"|\ *[%s]\ *" % Path.COMMANDS, path_str) + + path_list.reverse() + + command = None + current_pt = Point(0,0) + start_pt = None + + while path_list: + if path_list[-1].strip() in Path.COMMANDS: + last_command = command + command = path_list.pop().strip() + absolute = (command == command.upper()) + command = command.upper() + else: + if command is None: + raise ValueError("No command found at %d" % len(path_list)) + + if command == 'M': + # MoveTo + x = path_list.pop() + y = path_list.pop() + pt = Point(x, y) + if absolute: + current_pt = pt + else: + current_pt += pt + start_pt = current_pt + + self.items.append(MoveTo(current_pt)) + + # MoveTo with multiple coordinates means LineTo + command = 'L' + + elif command == 'Z': + # Close Path + l = Segment(current_pt, start_pt) + self.items.append(l) + current_pt = start_pt + + elif command in 'LHV': + # LineTo, Horizontal & Vertical line + # extra coord for H,V + if absolute: + x,y = current_pt.coord() + else: + x,y = (0,0) + + if command in 'LH': + x = path_list.pop() + if command in 'LV': + y = path_list.pop() + + pt = Point(x, y) + if not absolute: + pt += current_pt + + self.items.append(Segment(current_pt, pt)) + current_pt = pt + + elif command in 'CQ': + dimension = {'Q':3, 'C':4} + bezier_pts = [] + bezier_pts.append(current_pt) + for _ in range(1,dimension[command]): + x = path_list.pop() + y = path_list.pop() + pt = Point(x, y) + if not absolute: + pt += current_pt + bezier_pts.append(pt) + + self.items.append(Bezier(bezier_pts)) + current_pt = pt + + elif command in 'TS': + # number of points to read + num_pts = {'T':1, 'S':2} + # the control point, from previous Bezier to mirror + ctrl_pt = {'T':1, 'S':2} + # last command control + last = {'T': 'QT', 'S':'CS'} + + bezier_pts = [] + bezier_pts.append(current_pt) + + if last_command in last[command]: + pt0 = self.items[-1].control_point(ctrl_pt[command]) + else: + pt0 = current_pt + pt1 = current_pt + # Symmetrical of pt1 against pt0 + bezier_pts.append(pt1 + pt1 - pt0) + + for _ in range(0,num_pts[command]): + x = path_list.pop() + y = path_list.pop() + pt = Point(x, y) + if not absolute: + pt += current_pt + bezier_pts.append(pt) + + self.items.append(Bezier(bezier_pts)) + current_pt = pt + + elif command == 'A': + rx = path_list.pop() + ry = path_list.pop() + x_rotation = path_list.pop() + # Arc flags are not necessarily separated numbers + flags = path_list.pop().strip() + large_arc_flag = flags[0] + if large_arc_flag not in '01': + logger.error("Arc parsing failure") + break + + if len(flags) > 1: flags = flags[1:].strip() + else: flags = path_list.pop().strip() + sweep_flag = flags[0] + if sweep_flag not in '01': + logger.error("Arc parsing failure") + break + + if len(flags) > 1: x = flags[1:] + else: x = path_list.pop() + y = path_list.pop() + end_pt = Point(x, y) + if not absolute: end_pt += current_pt + self.items.append( + Arc(current_pt, rx, ry, x_rotation, large_arc_flag, sweep_flag, end_pt)) + current_pt = end_pt + + else: + path_list.pop() + + def __str__(self): + return '\n'.join(str(x) for x in self.items) + + def __repr__(self): + return '' + + def segments(self, precision=0) -> List[Segment]: + '''Return a list of segments, each segment is ended by a MoveTo. + A segment is a list of Points''' + ret = [] + # group items separated by MoveTo + for moveTo, group in itertools.groupby(self.items, + lambda x: isinstance(x, MoveTo)): + # Use only non MoveTo item + if not moveTo: + # Generate segments for each relevant item + seg = [x.segments(precision) for x in group] + # Merge all segments into one + ret.append(list(itertools.chain.from_iterable(seg))) + + return ret + + def simplify(self, precision:float) -> List[Segment]: + '''Simplify segment with precision: + Remove any point which are ~aligned''' + ret = [] + for seg in self.segments(precision): + ret.append(simplify_segment(seg, precision)) + + return ret + +class Polygon(Path): + '''SVG tag handler + A polygon has a space separated list of points in format x,y. + ''' + + # class Polygon handles the tag + tag = 'polygon' + + def __init__(self, elt, *args, **kwargs): + self.path_len = -1 + Transformable.__init__(self, elt, *args, **kwargs) + if elt is not None: + if elt.get('pathLength'): + self.path_len = int(elt.get('pathLength')) + self.parse(elt.get('points')) + + def parse(self, point_str): + '''Split the points from point_str and create a list of segments''' + start_pt = None + current_pt = None + + points = re.findall(number_re, point_str) + points.reverse() + while points: + start_pt = current_pt + current_pt = Point(points.pop(), points.pop()) + + if start_pt and current_pt: + self.items.append(Segment(start_pt, current_pt)) + + def __repr__(self) -> str: + return '' + + def segments(self, precision=0) -> List[Segment]: + ''' Return list of segments ''' + + seg = [x.segments(precision) for x in self.items] + + return [list(itertools.chain.from_iterable(seg))] + + +class Ellipse(Transformable): + '''SVG tag handler + An ellipse is created by the center point (center) + the x radius (rx) and the y radius (ry). + Setting these values will change the ellipse + regardless if it was created by an xml element. + + If provided xml has a 'd' attribute or path + then this will also parse that. + (This is for support of inkscape arc objects) + ''' + # class Ellipse handles the tag + tag = 'ellipse' + + def __init__(self, elt=None, *args, **kwargs): + Transformable.__init__(self, elt, *args, **kwargs) + self.arc = False + if elt is not None: + self.center = Point(self.xlength(elt.get('cx')), + self.ylength(elt.get('cy'))) + self.rx = self.length(elt.get('rx')) + self.ry = self.length(elt.get('ry')) + if elt.get('d') is not None: + self.arc = True + self.path = Path(elt) + self.path_str = elt.get('d') + else: + self.center = Point(0,0) + self.rx = 0 + self.ry = 0 + + def __repr__(self): + return '' + + def bbox(self) -> Tuple[Point, Point]: + '''Approximate the bounding box for the given ellipse by + decomposing the ellipse into a small number of segments. + + While there may be better ways of computing this + it is much easier to compute the bounding box of segments. + ''' + if self.arc: + return Transformable.bbox(self) + + points = self.segments((self.rx+self.ry) / 8) + if isinstance(points[0], Iterable): + points = list(itertools.chain.from_iterable(points)) + + xmin = min([p.x for p in points]) + xmax = max([p.x for p in points]) + ymin = min([p.y for p in points]) + ymax = max([p.y for p in points]) + + return (Point(xmin,ymin),Point(xmax,ymax)) + + def transform(self, matrix=None): + '''Apply the provided matrix. Default (None) + If no matrix is supplied then recursively apply + it's already existing matrix to all items. + Also apply to center, rx, and ry + ''' + if matrix is None: + matrix = self.matrix + else: + matrix *= self.matrix + self.transform_styles(matrix) + + self.center = matrix * self.center + self.rx = matrix.xscale()*self.rx + self.ry = matrix.yscale()*self.ry + self.rotation += math.degrees(matrix.rot().angle) + self.matrix= matrix + + def P(self, t) -> Point: + '''Return a Point on the Ellipse for t in [0..1] or % from angle 0 to the full circle. + Rotation is not handled in this function. + ''' + x = self.center.x + self.rx * math.cos(2 * math.pi * t) + y = self.center.y + self.ry * math.sin(2 * math.pi * t) + return Point(x,y) + + def segments(self, precision=0) -> List[Segment]: + '''Flatten all curves to segments with target length of precision''' + if self.arc: + segments = self.path.segments(precision) + return segments + if max(self.rx, self.ry) < precision: + return [[self.center]] + + p = [(0,self.P(0)), (1, self.P(1))] + d = 2 * max(self.rx, self.ry) + + while d > precision: + for (t1,_),(t2,_) in zip(p[:-1],p[1:]): + t = t1 + (t2 - t1)/2. + p.append((t, self.P(t))) + p.sort(key=operator.itemgetter(0)) + d = Segment(p[0][1],p[1][1]).length() + + ret = [x.rot(math.radians(self.rotation), x=self.center.x, y=self.center.y) for __,x in p] + return [ret] + + def simplify(self, __): + '''Return self because a 3 point representation is already simple''' + return self + +# An arc is an ellipse with a beginning and an end point instead of an entire circumference +class Arc(Ellipse): + '''This inherits from Ellipse but does not have a svg tag + Because there are no arc tags this class converts the + path data for an arc into an object that can be flattened. + ''' + + def __init__(self, start_pt, rx, ry, x_rotation, large_arc_flag, sweep_flag, end_pt): + Ellipse.__init__(self, None) + try: + self.rx = float(rx) + self.ry = float(ry) + self.rotation = float(x_rotation) + self.large_arc_flag = large_arc_flag=='1' + self.sweep_flag = sweep_flag=='1' + except: + pass + self.end_pts = [start_pt, end_pt] + self.angles = [] + + self.calculate_center() + + def __repr__(self): + return '' + + def calculate_center(self): + '''Calculate the center point of the arc from the + non-intuitively provided data in an svg path. + + This is done by creating rotated ellipses around + the start and end point. Then choosing the correct + intersection point based on the two arc choosing flags. + If there is no intersection then the center is the midpoint + between the beginning and end points. + ''' + angle = Angle(math.radians(self.rotation)) + + # set some variables that are used often to decrease size of final equations + pts = self.end_pts + cs2 = 2*angle.cos*angle.sin*(math.pow(self.ry, 2) - math.pow(self.rx, 2)) + rs = (math.pow(self.ry*angle.sin, 2) + math.pow(self.rx*angle.cos, 2)) + rc = (math.pow(self.ry*angle.cos, 2) + math.pow(self.rx*angle.sin, 2)) + + + # Create a line that passes through both intersection points + y = -pts[0].x*(cs2) + pts[1].x*cs2 - 2*pts[0].y*rs + 2*pts[1].y*rs + # Round to prevent floating point errors + y = round(y, 10) + # A vertical line will break the program so we cannot calculate with these equations + if y != 0: + # Finish calculating the line + m = ( -2*pts[0].x*rc + 2*pts[1].x*rc - pts[0].y*cs2 + pts[1].y*cs2 ) / -y + b = ( + math.pow(pts[0].x,2)*rc - math.pow(pts[1].x,2)*rc + pts[0].x*pts[0].y*cs2 - + pts[1].x*pts[1].y*cs2 + math.pow(pts[0].y,2)*(rs) - math.pow(pts[1].y,2)*rs + ) / -y + + # Now that we have a line we can setup a quadratic equation to solve for all intersection points + qa = rc + m*cs2 + math.pow(m,2)*rs + qb = -2*pts[0].x*rc + b*cs2 - pts[0].y*cs2 - m*pts[0].x*cs2 + 2*m*b*rs - 2*pts[0].y*m*rs + qc = ( + math.pow(pts[0].x,2)*rc - b*pts[0].x*cs2 + pts[0].x*pts[0].y*cs2 + math.pow(b,2)*rs - + 2*b*pts[0].y*rs + math.pow(pts[0].y,2)*rs - math.pow(self.rx*self.ry, 2) + ) + + else: + # When the slope is vertical we need to calculate with x instead of y + x = (pts[0].x+pts[1].x)/2 + m=0 + b=x + + # The quadratic formula but solving for y instead of x and only when the slope is vertical + qa = rs + qb = x*cs2 - pts[0].x*cs2 - 2*pts[0].y*rs + qc = ( + math.pow(x,2)*rc - 2*x*pts[0].x*rc + math.pow(pts[0].x,2)*rc - x*pts[0].y*cs2 + + pts[0].x*pts[0].y*cs2 + math.pow(pts[0].y,2)*rs - math.pow(self.rx*self.ry, 2) + ) + + # This is the value to see how many real solutions the quadratic equation has. + # if root is negative then there are only imaginary solutions or no real solutions + # if the root is 0 then there is one solution + # otherwise there are two solutions + root = math.pow(qb, 2) - 4*qa*qc + + # If there are no roots then we need to scale the arc to fit the points + if root < 0: + # Center point + point = Point((pts[0].x + pts[1].x)/2,(pts[0].y + pts[1].y)/2) + # Angle between center and one of the end points adjusted to remove rotation from original data + ptAng = math.atan2(self.end_pts[0].y-point.y, self.end_pts[0].x-point.x) - angle.angle + # Adjust the angle to compensate for ellipse irregularity + ptAng = math.atan((self.rx/self.ry) * math.tan(ptAng)) + # Calculate scaling factor between provided ellipse and actual end points + radius = math.sqrt(math.pow(self.rx*math.cos(ptAng),2) + math.pow(self.ry*math.sin(ptAng),2)) + dist = math.sqrt( math.pow(self.end_pts[0].x-point.x, 2)+math.pow(self.end_pts[0].y-point.y, 2)) + factor = dist/radius + self.rx *= factor + self.ry *= factor + + + # finish solving the quadratic equation and find the corresponding points on the intersection line + elif root == 0: + x_root = (-qb+math.sqrt(root))/(2*qa) + point = Point(x_root, x_root*m + b) + # Using the provided large_arc and sweep flags to choose the correct root + else: + x_roots = [(-qb+math.sqrt(root))/(2*qa), (-qb-math.sqrt(root))/(2*qa)] + points = [Point(x_roots[0], x_roots[0]*m + b), Point(x_roots[1], x_roots[1]*m + b)] + # Calculate the angle of the beginning point to the end point + + # If counterclockwise the two angles are the angle is within 180 degrees of each other: + # and no flags are set use the first center + # and the sweep flag is set use the second + # the large arc flag is set invert the previous selection + + # Don't save the angles because they are calculated from the first possible center. + # This may change so we'll just recalculate the angles later on + angles = [] + for pt in pts: + pt = Point(pt.x-points[0].x, pt.y-points[0].y) + pt.rot(math.radians(-self.rotation)) + pt = Point(pt.x/self.rx, pt.y/self.ry) + angles.append(math.atan2(pt.y,pt.x)%(math.pi*2)) + target = 0 + if self.sweep_flag: + target = 0 if (angles[0] - angles[1]) < 0 or (angles[0] - angles[1]) > math.pi else 1 + else: + target = 1 if (angles[0] - angles[1]) < 0 or (angles[0] - angles[1]) > math.pi else 0 + + point = points[target if not self.large_arc_flag else target ^ 1 ] + + + # Swap the x and y results from when the intersection line is vertical because we solved for y instead of x + # Also remove any insignificant floating point errors + if y == 0: + point = Point(round(point.y, 10), round(point.x, 10)) + else: + point = Point(round(point.x, 10), round(point.y, 10)) + self.center = point + + # Calculate start and end angle of the un-rotated arc + if len(self.angles) < 2: + self.angles = [] + for pt in self.end_pts: + pt = Point(pt.x-self.center.x, pt.y-self.center.y) + pt = pt.rot(math.radians(-self.rotation)) + pt = Point(pt.x/self.rx, pt.y/self.ry) + self.angles.append(math.atan2(pt.y,pt.x)) + + if not self.sweep_flag and self.angles[0] < self.angles[1]: + self.angles[0] += 2*math.pi + elif self.sweep_flag and self.angles[1] < self.angles[0]: + self.angles[1] += 2*math.pi + + def transform(self, matrix=None): + super().transform(matrix) + self.end_pts[0] = self.matrix * self.end_pts[0] + self.end_pts[1] = self.matrix * self.end_pts[1] + + def segments(self, precision=0) -> List[Segment]: + '''This returns segments as expected by the + Path object. (A list of points. Not a list of lists of points) + ''' + if max(self.rx, self.ry) < precision: + return self.end_pts + return Ellipse.segments(self, precision)[0] + + def P(self, t) -> Point: + '''Return a Point on the Arc for t in [0..1] where t is the % from + the start angle to the end angle. + + Final angle transformation is handled in Ellipse.segments + ''' + x = self.center.x + self.rx * math.cos(((self.angles[1] - self.angles[0]) * t) + self.angles[0]) + y = self.center.y + self.ry * math.sin(((self.angles[1] - self.angles[0]) * t) + self.angles[0]) + return Point(x,y) + + +# A circle is a special type of ellipse where rx = ry = radius +class Circle(Ellipse): + '''SVG tag handler + This is an ellipse by rx and ry are equal. + ''' + # class Circle handles the tag + tag = 'circle' + + def __init__(self, elt=None, *args, **kwargs): + if elt is not None: + elt.set('rx', elt.get('r')) + elt.set('ry', elt.get('r')) + Ellipse.__init__(self, elt, *args, **kwargs) + + def __repr__(self): + return '' + +class Rect(Path): + '''SVG tag handler + This decompiles a rectangle svg xml element into + essentially a path with 4 segments. + + P1 and P2 are the opposing corner points. + + As of now corner radii are not supported. + ''' + # class Rect handles the tag + tag = 'rect' + + def __init__(self, elt=None, *args, **kwargs): + Transformable.__init__(self, elt, *args, **kwargs) + if elt is not None: + p = Point(self.xlength(elt.get('x')), + self.ylength(elt.get('y'))) + width = self.xlength(elt.get("width")) + height = self.xlength(elt.get("height")) + + rx = self.xlength(elt.get('rx')) + ry = self.xlength(elt.get('ry')) + if not rx: rx = ry if ry else 0 + if not ry: ry = rx if rx else 0 + if rx > width/2: rx = width/2 + if ry > height/2: ry = width/2 + if rx or ry: + cmd = f'''M{p.x+rx} {p.y} a{rx} {ry} 0 0 0 {-rx} {ry} v{height-(ry*2)} + a{rx} {ry} 0 0 0 {rx} {ry} h{width-(rx*2)} + a{rx} {ry} 0 0 0 {rx} {-ry} v{-(height-(ry*2))} + a{rx} {ry} 0 0 0 {-rx} {-ry} h{-(width-(rx*2))} z''' + else: + cmd = f'M{p.x},{p.y}v{height}h{width}v{-height}h{-width}' + + self.p = p + self.width = width + self.height = height + self.rx = rx + self.ry = ry + + self.parse(cmd) + + def __repr__(self): + return '' + +class Line(Transformable): + '''SVG tag handler + + This is essentially a wrapper around the Segment class + ''' + # class Line handles the tag + tag = 'line' + + def __init__(self, elt=None, *args, **kwargs): + Transformable.__init__(self, elt, *args, **kwargs) + if elt is not None: + self.P1 = Point(self.xlength(elt.get('x1')), + self.ylength(elt.get('y1'))) + self.P2 = Point(self.xlength(elt.get('x2')), + self.ylength(elt.get('y2'))) + self.segment = Segment(self.P1, self.P2) + + def __repr__(self): + return '' + + def bbox(self) -> Tuple[Point, Point]: + '''Bounding box''' + xmin = min([p.x for p in (self.P1, self.P2)]) + xmax = max([p.x for p in (self.P1, self.P2)]) + ymin = min([p.y for p in (self.P1, self.P2)]) + ymax = max([p.y for p in (self.P1, self.P2)]) + + return (Point(xmin,ymin), Point(xmax,ymax)) + + def transform(self, matrix=None): + '''Apply the provided matrix. Default (None) + If no matrix is supplied then recursively apply + it's already existing matrix to all items. + ''' + if matrix is None: + matrix = self.matrix + else: + matrix *= self.matrix + self.transform_styles(matrix) + + self.P1 = matrix * self.P1 + self.P2 = matrix * self.P2 + self.segment = Segment(self.P1, self.P2) + + def segments(self, __=0) -> List[Segment]: + '''Return the segment of the line''' + return [self.segment.segments()] + + +class Text(Transformable): + '''SVG tag handler + Take provided xml text element and convert using ttf and otf fonts + into path element that can be used. + + setting Text.default_font is important. If the listed font + cannot be found this is the fall back value. + + A list of fonts installed on the system can be found by calling + Text.load_system_fonts(...) + this keeps all found font in memory after first time call to + improve performance. + + All distinct text element, those that have different start locations + or fonts, are stored in text in a list. + + Adding new strings can be done by calling add_text(...) + and removing strings is done by removing the item from the text list + + Once all strings are properly configured in the text list running + convert_to_path will append a list of path elements to the paths variable + + The bounding box will not report a valid size until convert_to_path has been ran. + ''' + # class Text handles the tag + tag = 'text' + + default_font = None + _system_fonts = {} + _os_font_paths = { + "Darwin": ["/Library/Fonts", "~/Library/Fonts"], + "Linux": ["/usr/share/fonts","/usr/local/share/fonts","~/.local/share/fonts"], + "Windows": ["C:/Windows/Fonts", "~/AppData/Local/Microsoft/Windows/Fonts"] + } + + def __init__(self, elt=None, parent=None, *args, **kwargs): + Transformable.__init__(self, elt, *args, **kwargs) + + self.bbox_points = [Point(0,0), Point(0,0)] + self.paths = [] + + if elt is not None: + self.parse(elt, parent) + if parent is None: + self.convert_to_path(auto_transform=False) + else: + self.origin = Point(0,0) + self.font_family = Text.default_font + self.size = 12 + self.bold = "normal" + self.italic = "normal" + if self.font_family: + self.font_file = self.find_font_file() + self.text = [] + + def set_font(self, font=None, bold=None, italic=None, size=None): + '''Set the font of the current text element. + font is expected to be a string of the font family name. + bold is expected Boolean + italic is expected Boolean + size is expected int, but can work with string ending in px + ''' + font = font if font else self.font_family + bold = bold if bold else (self.bold.lower() != "normal") + italic = italic if italic else (self.italic.lower() != "normal") + size = size if size else self.size + if isinstance(size, str): + size = float(size.strip("px")) + + self.font_family = font + self.size = size + self.bold = "normal" if not bold else "bold" + self.italic = "normal" if not italic else "italic" + self.font_file = self.find_font_file() + + + def add_text(self, text, origin=Point(0,0), inherit=True): + '''Add text the list of text objects + if the origin is not different then the parents origin or + inherit is set to False then a new text element will + be created an added to the strings tuple in the text list. + ''' + if origin == self.origin and inherit: + self.text.append((text, self)) + else: + new_line = Text() + new_line.set_font( + font=self.font_family, + bold=(self.bold != "normal"), + italic=(self.italic != "normal"), + size=self.size + ) + + new_line.origin = origin + self.text.append((text, new_line)) + + + def parse(self, elt, parent): + '''Read the useful data from the xml element. + Since text tags can have nested text tags + parse can be called multiple times for one text tag. + However all nested tags should have parent set so + they can inherit and append the proper values + from their immediate parent + ''' + x = elt.get('x') + y = elt.get('y') + + # It seems that any values in style that override these values take precedence + self.font_configs = { + "font-family": elt.get('font-family'), + "font-size": elt.get('font-size'), + "font-weight": elt.get('font-weight'), + "font-style": elt.get('font-style'), + } + for style in self.style: + if style in self.font_configs.keys() and self.style[style]: + self.font_configs[style] = self.style[style] + + if isinstance(self.font_configs["font-size"], str): + self.font_configs["font-size"] = float(self.font_configs["font-size"].strip("px")) + + for config in self.font_configs: + if self.font_configs[config] is None and parent is not None: + self.font_configs[config] = parent.font_configs[config] + + self.font_family = self.font_configs["font-family"] + self.size = self.font_configs["font-size"] + self.bold = self.font_configs["font-weight"] + self.italic = self.font_configs["font-style"] + + self.font_file = self.find_font_file() + + if parent is not None: + x = parent.origin.x if x is None else float(x) + y = parent.origin.y if y is None else float(y) + x = 0 if x is None else float(x) + y = 0 if y is None else float(y) + self.origin = Point(x,y) + + self.text = [] if elt.text is None else [(elt.text, self)] + for child in list(elt): + Text(child, self) + if parent is not None: + parent.text.extend(self.text) + if elt.tail is not None: + parent.text.append((elt.tail, parent)) + + del self.font_configs + + + def find_font_file(self): + '''This will look through the indexed fonts and + attempt to find one with a matching font name and text style. + + -- Faux font styles are not supported == + + If the styling cannot be found it will fall back to either + italic or bold if both were asked for and there wasn't a style + with both or regular if italic or bold are set but not found. + + If the target font cannot be found then the default is used if set and found. + ''' + if self.font_family is None: + if Text.default_font is None: + global _font_warning_sent + if not _font_warning_sent: + logger.error("Unable to find font because no font was specified.") + _font_warning_sent = True + return None + self.font_family = Text.default_font + fonts = [fnt.strip().strip("'") for fnt in self.font_family.split(",")] + if Text.default_font is not None: fonts.append(Text.default_font) + + font_files = None + target_font = None + for fnt in fonts: + if Text.load_system_fonts().get(fnt) is not None: + target_font = fnt + font_files = Text.load_system_fonts().get(fnt) + break + if font_files is None: + # We are unable to find a font and since there is no default font stop building font data + logger.error("Unable to find font(s) \"{}\"{}".format( + self.font_family, + " and no default font specified" if Text.default_font is None else f" or default font \"{Text.default_font}\"" + )) + self.paths = [] + return + + bold = self.bold is not None and self.bold.lower() != "normal" + italic = self.italic is not None and self.italic.lower() != "normal" + + reg = ["Regular", "Book"] + bol = ["Bold", "Demibold"] + ita = ["Italic", "Oblique"] + + search = reg + if bold and not italic: + search = bol + elif italic and not bold: + search = ita + elif italic and bold: + search = [f"{b} {i}" if n == 0 else f"{i} {b}" for b in bol for i in ita for n in range(2)] + tar_font = list(filter(None, [font_files.get(style) for style in search])) + if len(tar_font) == 0 and len(font_files.keys()) == 1: + tar_font = [font_files[list(font_files.keys())[0]]] + logger.warning("Font \"{}\" does not natively support style \"{}\" using \"{}\" instead".format( + target_font, search[0], list(font_files.keys())[0])) + elif len(tar_font) == 0 and italic and bold: + orig_search = search[0] + search = [] + search.extend(ita) + search.extend(bol) + search.extend(reg) + search.extend(list(font_files.keys())) + for style in search: + if font_files.get(style) is not None: + tar_font = [font_files[style]] + logger.warning("Font \"{}\" does not natively support style \"{}\" using \"{}\" instead".format( + target_font, orig_search, style)) + break + return tar_font[0] + + + def convert_to_path(self, auto_transform=True): + ''' Read the vector data from the ttf/otf file and + convert it into a path string for each letter and + parse the path string by a Path instance. + + if auto_transform is True then this calls self.transform() + at the end to apply all transformations on the paths. + + This should only be called once so double check transform() + is never called elsewhere. + ''' + self.paths = [] + if not self.text: return + prev_origin = self.text[0][1].origin + + offset = Point(prev_origin.x, prev_origin.y) + for text, attrib in self.text: + + if attrib.font_file is None or attrib.font_family is None: + continue + size = attrib.size + ttf = ttFont.TTFont(attrib.font_file) + offset.y = attrib.origin.y + ttf["head"].unitsPerEm + scale = size/offset.y + + if prev_origin != attrib.origin: + prev_origin = attrib.origin + offset.x = attrib.origin.x + + path = [] + for char in text: + + path_buff = "" + try: glf = ttf.getGlyphSet()[ttf.getBestCmap()[ord(char)]] + except KeyError: + logger.warning('Unsupported character in element "{}"'.format(char)) + #txt = txt.replace(char, "") + continue + + pen = SVGPathPen(ttf.getGlyphSet()) + glf.draw(pen) + + for cmd in pen._commands: + path_buff += cmd + ' ' + + if len(path_buff) > 0: + path.append(Path()) + path[-1].parse(path_buff) + # Apply the scaling then the translation + translate = Matrix([1,0,0,-1,offset.x,size+attrib.origin.y]) * Matrix([scale,0,0,scale,0,0]) + # This queues the translations until .transform() is called + path[-1].matrix = translate * path[-1].matrix + + offset.x += (scale*glf.width) + + self.paths.append(path) + if auto_transform: + self.transform() + + def bbox(self) -> Tuple[Point, Point]: + '''Find the bounding box of all the paths that make + each letter. + This will only work if there are available paths. + ''' + if self.paths is None or len(self.paths) == 0: + return [Point(0,0),Point(0,0)] + + b_boxes = [path.bbox() for paths in self.paths for path in paths] + + return ( + Point(min(b_boxes, key=lambda v: v[0].x)[0].x, min(b_boxes, key=lambda v: v[0].y)[0].y), + Point(max(b_boxes, key=lambda v: v[1].x)[1].x, max(b_boxes, key=lambda v: v[1].y)[1].y), + ) + + def transform(self, matrix=None): + '''Apply the provided matrix. Default (None) + If no matrix is supplied then recursively apply + it's already existing matrix to all items. + ''' + if matrix is None: + matrix = self.matrix + else: + matrix *= self.matrix + self.transform_styles(matrix) + + self.origin = matrix * self.origin + for paths in self.paths: + for path in paths: + path.transform(matrix) + + def segments(self, precision=0) -> List[Segment]: + '''Get a list of all points in all paths + with provide precision. + This will only work if there are available paths. + ''' + segments = [] + for paths in self.paths: + for path in paths: + segments.extend(path.segments(precision)) + return segments + + @staticmethod + def load_system_fonts(reload:bool=False) -> List[dict]: + '''Find all fonts in common locations on the file system + To properly read all fonts they need to be parsed so this + is inherently slow on systems with many fonts. + To prevent long parsing time all the results are cached + and the cached results are returned next time this function + is called. + If a force reload of all indexed fonts is desirable setting + reload to True will clear the cache and re-index the system. + ''' + if reload: + Text._system_fonts = {} + if len(Text._system_fonts.keys()) < 1: + fonts_files = [] + logger.info("Loading system fonts.") + for path in Text._os_font_paths[platform.system()]: + try: + fonts_files.extend([os.path.join(dp, f) for dp, dn, fn in os.walk(os.path.expanduser(path)) for f in fn]) + except: + pass + + for font_file in fonts_files: + try: + font = ttFont.TTFont(font_file) + name = font["name"].getName(1,1,0).toStr() + style = font["name"].getName(2,1,0).toStr() + if Text._system_fonts.get(name) is None: + Text._system_fonts[name] = {style:font_file} + elif Text._system_fonts[name].get(style) is None: + Text._system_fonts[name][style] = font_file + except: + pass + logger.debug(f" Found {len(Text._system_fonts.keys())} fonts in system") + return Text._system_fonts + + +class JSONEncoder(json.JSONEncoder): + ''' overwrite JSONEncoder for svg classes which have defined a .json() method ''' + def default(self, obj): + ''' overwrite default function to handle svg classes ''' + if not isinstance(obj, tuple(svgClass.values() + [Svg])): + return json.JSONEncoder.default(self, obj) + + if not hasattr(obj, 'json'): + return repr(obj) + + return obj.json() + +## Code executed on module load ## + +# Make fontTools more quiet +loggingTools.configLogger(level=logging.INFO) + + +# SVG tag handler classes are initialized here +# (classes must be defined before) + +svgClass = {} +# Register all classes with attribute 'tag' in svgClass dictionary +for name, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): + tag = getattr(cls, 'tag', None) + if tag: + svgClass[svg_ns + tag] = cls diff --git a/svg2mod/svg/svg/__init__.py b/svg2mod/svg/svg/__init__.py deleted file mode 100644 index 3090eb7..0000000 --- a/svg2mod/svg/svg/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -#__all__ = ['geometry', 'svg'] - -from .svg import * - -def parse(filename): - f = svg.Svg(filename) - return f - diff --git a/svg2mod/svg/svg/svg.py b/svg2mod/svg/svg/svg.py deleted file mode 100644 index 244fdaa..0000000 --- a/svg2mod/svg/svg/svg.py +++ /dev/null @@ -1,711 +0,0 @@ -# SVG parser in Python - -# Copyright (C) 2013 -- CJlano < cjlano @ free.fr > - -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -from __future__ import absolute_import -import traceback -import sys -import os -import copy -import re -import xml.etree.ElementTree as etree -import itertools -import operator -import json -from .geometry import * - - -svg_ns = '{http://www.w3.org/2000/svg}' - -# Regex commonly used -number_re = r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?' -unit_re = r'em|ex|px|in|cm|mm|pt|pc|%' - -# Unit converter -unit_convert = { - None: 1, # Default unit (same as pixel) - 'px': 1, # px: pixel. Default SVG unit - 'em': 10, # 1 em = 10 px FIXME - 'ex': 5, # 1 ex = 5 px FIXME - 'in': 96, # 1 in = 96 px - 'cm': 96 / 2.54, # 1 cm = 1/2.54 in - 'mm': 96 / 25.4, # 1 mm = 1/25.4 in - 'pt': 96 / 72.0, # 1 pt = 1/72 in - 'pc': 96 / 6.0, # 1 pc = 1/6 in - '%' : 1 / 100.0 # 1 percent - } - -class Transformable: - '''Abstract class for objects that can be geometrically drawn & transformed''' - def __init__(self, elt=None): - # a 'Transformable' is represented as a list of Transformable items - self.items = [] - self.id = hex(id(self)) - # Unit transformation matrix on init - self.matrix = Matrix() - self.viewport = Point(800, 600) # default viewport is 800x600 - if elt is not None: - self.id = elt.get('id', self.id) - # Parse transform attibute to update self.matrix - self.getTransformations(elt) - - def bbox(self): - '''Bounding box''' - bboxes = [x.bbox() for x in self.items] - if len( bboxes ) < 1: - return (Point(0, 0), Point(0, 0)) - xmin = min([b[0].x for b in bboxes]) - xmax = max([b[1].x for b in bboxes]) - ymin = min([b[0].y for b in bboxes]) - ymax = max([b[1].y for b in bboxes]) - - return (Point(xmin,ymin), Point(xmax,ymax)) - - # Parse transform field - def getTransformations(self, elt): - t = elt.get('transform') - if t is None: return - - svg_transforms = [ - 'matrix', 'translate', 'scale', 'rotate', 'skewX', 'skewY'] - - # match any SVG transformation with its parameter (until final parenthese) - # [^)]* == anything but a closing parenthese - # '|'.join == OR-list of SVG transformations - transforms = re.findall( - '|'.join([x + '[^)]*\)' for x in svg_transforms]), t) - - for t in transforms: - op, arg = t.split('(') - op = op.strip() - # Keep only numbers - arg = [float(x) for x in re.findall(number_re, arg)] - print('transform: ' + op + ' '+ str(arg)) - - if op == 'matrix': - self.matrix *= Matrix(arg) - - if op == 'translate': - tx = arg[0] - if len(arg) == 1: ty = 0 - else: ty = arg[1] - self.matrix *= Matrix([1, 0, 0, 1, tx, ty]) - - if op == 'scale': - sx = arg[0] - if len(arg) == 1: sy = sx - else: sy = arg[1] - self.matrix *= Matrix([sx, 0, 0, sy, 0, 0]) - - if op == 'rotate': - cosa = math.cos(math.radians(arg[0])) - sina = math.sin(math.radians(arg[0])) - if len(arg) != 1: - tx, ty = arg[1:3] - self.matrix *= Matrix([1, 0, 0, 1, tx, ty]) - self.matrix *= Matrix([cosa, sina, -sina, cosa, 0, 0]) - if len(arg) != 1: - self.matrix *= Matrix([1, 0, 0, 1, -tx, -ty]) - - if op == 'skewX': - tana = math.tan(math.radians(arg[0])) - self.matrix *= Matrix([1, 0, tana, 1, 0, 0]) - - if op == 'skewY': - tana = math.tan(math.radians(arg[0])) - self.matrix *= Matrix([1, tana, 0, 1, 0, 0]) - - def transform(self, matrix=None): - if matrix is None: - matrix = self.matrix - else: - matrix *= self.matrix - #print( "do transform: {}: {}".format( self.__class__.__name__, matrix ) ) - #print( "do transform: {}: {}".format( self, matrix ) ) - #traceback.print_stack() - for x in self.items: - x.transform(matrix) - - def length(self, v, mode='xy'): - # Handle empty (non-existing) length element - if v is None: - return 0 - - # Get length value - m = re.search(number_re, v) - if m: value = m.group(0) - else: raise TypeError(v + 'is not a valid length') - - # Get length unit - m = re.search(unit_re, v) - if m: unit = m.group(0) - else: unit = None - - if unit == '%': - if mode == 'x': - return float(value) * unit_convert[unit] * self.viewport.x - if mode == 'y': - return float(value) * unit_convert[unit] * self.viewport.y - if mode == 'xy': - return float(value) * unit_convert[unit] * self.viewport.x # FIXME - - return float(value) * unit_convert[unit] - - def xlength(self, x): - return self.length(x, 'x') - def ylength(self, y): - return self.length(y, 'y') - - def flatten(self): - '''Flatten the SVG objects nested list into a flat (1-D) list, - removing Groups''' - # http://rightfootin.blogspot.fr/2006/09/more-on-python-flatten.html - # Assigning a slice a[i:i+1] with a list actually replaces the a[i] - # element with the content of the assigned list - i = 0 - flat = copy.deepcopy(self.items) - while i < len(flat): - while isinstance(flat[i], Group): - flat[i:i+1] = flat[i].items - i += 1 - return flat - - def scale(self, ratio): - for x in self.items: - x.scale(ratio) - return self - - def translate(self, offset): - for x in self.items: - x.translate(offset) - return self - - def rotate(self, angle): - for x in self.items: - x.rotate(angle) - return self - -class Svg(Transformable): - '''SVG class: use parse to parse a file''' - # class Svg handles the tag - # tag = 'svg' - - def __init__(self, filename=None): - Transformable.__init__(self) - if filename: - self.parse(filename) - - def parse(self, filename): - self.filename = filename - tree = etree.parse(filename) - self.root = tree.getroot() - if self.root.tag != svg_ns + 'svg': - raise TypeError('file %s does not seem to be a valid SVG file', filename) - - # Create a top Group to group all other items (useful for viewBox elt) - top_group = Group() - self.items.append(top_group) - - # SVG dimension - width = self.xlength(self.root.get('width')) - height = self.ylength(self.root.get('height')) - # update viewport - top_group.viewport = Point(width, height) - - # viewBox - if self.root.get('viewBox') is not None: - viewBox = re.findall(number_re, self.root.get('viewBox')) - sx = width / float(viewBox[2]) - sy = height / float(viewBox[3]) - tx = -float(viewBox[0]) - ty = -float(viewBox[1]) - top_group.matrix = Matrix([sx, 0, 0, sy, tx, ty]) - - # Parse XML elements hierarchically with groups - top_group.append(self.root) - - self.transform() - - def title(self): - t = self.root.find(svg_ns + 'title') - if t is not None: - return t - else: - return os.path.splitext(os.path.basename(self.filename))[0] - - def json(self): - return self.items - - -class Group(Transformable): - '''Handle svg elements''' - # class Group handles the tag - tag = 'g' - - def __init__(self, elt=None): - Transformable.__init__(self, elt) - - self.name = "" - if elt is not None: - - for id, value in elt.attrib.iteritems(): - - id = self.parse_name( id ) - if id[ "name" ] == "label": - self.name = value - - @staticmethod - def parse_name( tag ): - m = re.match( r'({(.+)})?(.+)', tag ) - return { - 'namespace' : m.group( 2 ), - 'name' : m.group( 3 ), - } - - def append(self, element): - for elt in element: - elt_class = svgClass.get(elt.tag, None) - if elt_class is None: - print('No handler for element %s' % elt.tag) - continue - # instanciate elt associated class (e.g. : item = Path(elt) - item = elt_class(elt) - # Apply group matrix to the newly created object - # Actually, this is effectively done in Svg.__init__() through call to - # self.transform(), so doing it here will result in the transformations - # being applied twice. - #item.matrix = self.matrix * item.matrix - item.viewport = self.viewport - - self.items.append(item) - # Recursively append if elt is a (group) - if elt.tag == svg_ns + 'g': - item.append(elt) - - def __repr__(self): - return ': ' + repr(self.items) - - def json(self): - return {'Group ' + self.id + " ({})".format( self.name ) : self.items} - -class Matrix: - ''' SVG transformation matrix and its operations - a SVG matrix is represented as a list of 6 values [a, b, c, d, e, f] - (named vect hereafter) which represent the 3x3 matrix - ((a, c, e) - (b, d, f) - (0, 0, 1)) - see http://www.w3.org/TR/SVG/coords.html#EstablishingANewUserSpace ''' - - def __init__(self, vect=[1, 0, 0, 1, 0, 0]): - # Unit transformation vect by default - if len(vect) != 6: - raise ValueError("Bad vect size %d" % len(vect)) - self.vect = list(vect) - - def __mul__(self, other): - '''Matrix multiplication''' - if isinstance(other, Matrix): - a = self.vect[0] * other.vect[0] + self.vect[2] * other.vect[1] - b = self.vect[1] * other.vect[0] + self.vect[3] * other.vect[1] - c = self.vect[0] * other.vect[2] + self.vect[2] * other.vect[3] - d = self.vect[1] * other.vect[2] + self.vect[3] * other.vect[3] - e = self.vect[0] * other.vect[4] + self.vect[2] * other.vect[5] \ - + self.vect[4] - f = self.vect[1] * other.vect[4] + self.vect[3] * other.vect[5] \ - + self.vect[5] - return Matrix([a, b, c, d, e, f]) - - elif isinstance(other, Point): - x = other.x * self.vect[0] + other.y * self.vect[2] + self.vect[4] - y = other.x * self.vect[1] + other.y * self.vect[3] + self.vect[5] - return Point(x,y) - - else: - return NotImplemented - - def __str__(self): - return str(self.vect) - - def xlength(self, x): - return x * self.vect[0] - def ylength(self, y): - return y * self.vect[3] - - -COMMANDS = 'MmZzLlHhVvCcSsQqTtAa' - -class Path(Transformable): - '''SVG ''' - # class Path handles the tag - tag = 'path' - - def __init__(self, elt=None): - Transformable.__init__(self, elt) - if elt is not None: - self.style = elt.get('style') - self.parse(elt.get('d')) - - def parse(self, pathstr): - """Parse path string and build elements list""" - - pathlst = re.findall(number_re + r"|\ *[%s]\ *" % COMMANDS, pathstr) - - pathlst.reverse() - - command = None - current_pt = Point(0,0) - start_pt = None - - while pathlst: - if pathlst[-1].strip() in COMMANDS: - last_command = command - command = pathlst.pop().strip() - absolute = (command == command.upper()) - command = command.upper() - else: - if command is None: - raise ValueError("No command found at %d" % len(pathlst)) - - if command == 'M': - # MoveTo - x = pathlst.pop() - y = pathlst.pop() - pt = Point(x, y) - if absolute: - current_pt = pt - else: - current_pt += pt - start_pt = current_pt - - self.items.append(MoveTo(current_pt)) - - # MoveTo with multiple coordinates means LineTo - command = 'L' - - elif command == 'Z': - # Close Path - l = Segment(current_pt, start_pt) - self.items.append(l) - - - elif command in 'LHV': - # LineTo, Horizontal & Vertical line - # extra coord for H,V - if absolute: - x,y = current_pt.coord() - else: - x,y = (0,0) - - if command in 'LH': - x = pathlst.pop() - if command in 'LV': - y = pathlst.pop() - - pt = Point(x, y) - if not absolute: - pt += current_pt - - self.items.append(Segment(current_pt, pt)) - current_pt = pt - - elif command in 'CQ': - dimension = {'Q':3, 'C':4} - bezier_pts = [] - bezier_pts.append(current_pt) - for i in range(1,dimension[command]): - x = pathlst.pop() - y = pathlst.pop() - pt = Point(x, y) - if not absolute: - pt += current_pt - bezier_pts.append(pt) - - self.items.append(Bezier(bezier_pts)) - current_pt = pt - - elif command in 'TS': - # number of points to read - nbpts = {'T':1, 'S':2} - # the control point, from previous Bezier to mirror - ctrlpt = {'T':1, 'S':2} - # last command control - last = {'T': 'QT', 'S':'CS'} - - bezier_pts = [] - bezier_pts.append(current_pt) - - if last_command in last[command]: - pt0 = self.items[-1].control_point(ctrlpt[command]) - else: - pt0 = current_pt - pt1 = current_pt - # Symetrical of pt1 against pt0 - bezier_pts.append(pt1 + pt1 - pt0) - - for i in range(0,nbpts[command]): - x = pathlst.pop() - y = pathlst.pop() - pt = Point(x, y) - if not absolute: - pt += current_pt - bezier_pts.append(pt) - - self.items.append(Bezier(bezier_pts)) - current_pt = pt - - elif command == 'A': - rx = pathlst.pop() - ry = pathlst.pop() - xrot = pathlst.pop() - # Arc flags are not necesarily sepatated numbers - flags = pathlst.pop().strip() - large_arc_flag = flags[0] - if large_arc_flag not in '01': - print('Arc parsing failure') - break - - if len(flags) > 1: flags = flags[1:].strip() - else: flags = pathlst.pop().strip() - sweep_flag = flags[0] - if sweep_flag not in '01': - print('Arc parsing failure') - break - - if len(flags) > 1: x = flags[1:] - else: x = pathlst.pop() - y = pathlst.pop() - # TODO - print('ARC: ' + - ', '.join([rx, ry, xrot, large_arc_flag, sweep_flag, x, y])) -# self.items.append( -# Arc(rx, ry, xrot, large_arc_flag, sweep_flag, Point(x, y))) - - else: - pathlst.pop() - - def __str__(self): - return '\n'.join(str(x) for x in self.items) - - def __repr__(self): - return '' - - def segments(self, precision=0): - '''Return a list of segments, each segment is ended by a MoveTo. - A segment is a list of Points''' - ret = [] - # group items separated by MoveTo - for moveTo, group in itertools.groupby(self.items, - lambda x: isinstance(x, MoveTo)): - # Use only non MoveTo item - if not moveTo: - # Generate segments for each relevant item - seg = [x.segments(precision) for x in group] - # Merge all segments into one - ret.append(list(itertools.chain.from_iterable(seg))) - - return ret - - def simplify(self, precision): - '''Simplify segment with precision: - Remove any point which are ~aligned''' - ret = [] - for seg in self.segments(precision): - ret.append(simplify_segment(seg, precision)) - - return ret - -class Ellipse(Transformable): - '''SVG ''' - # class Ellipse handles the tag - tag = 'ellipse' - - def __init__(self, elt=None): - Transformable.__init__(self, elt) - if elt is not None: - self.center = Point(self.xlength(elt.get('cx')), - self.ylength(elt.get('cy'))) - self.rx = self.length(elt.get('rx')) - self.ry = self.length(elt.get('ry')) - self.style = elt.get('style') - - def __repr__(self): - return '' - - def bbox(self): - '''Bounding box''' - pmin = self.center - Point(self.rx, self.ry) - pmax = self.center + Point(self.rx, self.ry) - return (pmin, pmax) - - def transform(self, matrix): - self.center = self.matrix * self.center - self.rx = self.matrix.xlength(self.rx) - self.ry = self.matrix.ylength(self.ry) - - def scale(self, ratio): - self.center *= ratio - self.rx *= ratio - self.ry *= ratio - def translate(self, offset): - self.center += offset - def rotate(self, angle): - self.center = self.center.rot(angle) - - def P(self, t): - '''Return a Point on the Ellipse for t in [0..1]''' - x = self.center.x + self.rx * math.cos(2 * math.pi * t) - y = self.center.y + self.ry * math.sin(2 * math.pi * t) - return Point(x,y) - - def segments(self, precision=0): - if max(self.rx, self.ry) < precision: - return [[self.center]] - - p = [(0,self.P(0)), (1, self.P(1))] - d = 2 * max(self.rx, self.ry) - - while d > precision: - for (t1,p1),(t2,p2) in zip(p[:-1],p[1:]): - t = t1 + (t2 - t1)/2. - d = Segment(p1, p2).pdistance(self.P(t)) - p.append((t, self.P(t))) - p.sort(key=operator.itemgetter(0)) - - ret = [x for t,x in p] - return [ret] - - def simplify(self, precision): - return self - -# A circle is a special type of ellipse where rx = ry = radius -class Circle(Ellipse): - '''SVG ''' - # class Circle handles the tag - tag = 'circle' - - def __init__(self, elt=None): - if elt is not None: - elt.set('rx', elt.get('r')) - elt.set('ry', elt.get('r')) - Ellipse.__init__(self, elt) - - def __repr__(self): - return '' - -class Rect(Transformable): - '''SVG ''' - # class Rect handles the tag - tag = 'rect' - - def __init__(self, elt=None): - Transformable.__init__(self, elt) - if elt is not None: - self.P1 = Point(self.xlength(elt.get('x')), - self.ylength(elt.get('y'))) - - self.P2 = Point(self.P1.x + self.xlength(elt.get('width')), - self.P1.y + self.ylength(elt.get('height'))) - - def __repr__(self): - return '' - - def bbox(self): - '''Bounding box''' - xmin = min([p.x for p in (self.P1, self.P2)]) - xmax = max([p.x for p in (self.P1, self.P2)]) - ymin = min([p.y for p in (self.P1, self.P2)]) - ymax = max([p.y for p in (self.P1, self.P2)]) - - return (Point(xmin,ymin), Point(xmax,ymax)) - - def transform(self, matrix): - self.P1 = self.matrix * self.P1 - self.P2 = self.matrix * self.P2 - - def segments(self, precision=0): - # A rectangle is built with a segment going thru 4 points - ret = [] - Pa = Point(self.P1.x, self.P2.y) - Pb = Point(self.P2.x, self.P1.y) - - ret.append([self.P1, Pa, self.P2, Pb, self.P1]) - return ret - - def simplify(self, precision): - return self.segments(precision) - -class Line(Transformable): - '''SVG ''' - # class Line handles the tag - tag = 'line' - - def __init__(self, elt=None): - Transformable.__init__(self, elt) - if elt is not None: - self.P1 = Point(self.xlength(elt.get('x1')), - self.ylength(elt.get('y1'))) - self.P2 = Point(self.xlength(elt.get('x2')), - self.ylength(elt.get('y2'))) - self.segment = Segment(self.P1, self.P2) - - def __repr__(self): - return '' - - def bbox(self): - '''Bounding box''' - xmin = min([p.x for p in (self.P1, self.P2)]) - xmax = max([p.x for p in (self.P1, self.P2)]) - ymin = min([p.y for p in (self.P1, self.P2)]) - ymax = max([p.y for p in (self.P1, self.P2)]) - - return (Point(xmin,ymin), Point(xmax,ymax)) - - def transform(self, matrix): - self.P1 = self.matrix * self.P1 - self.P2 = self.matrix * self.P2 - self.segment = Segment(self.P1, self.P2) - - def segments(self, precision=0): - return [self.segment.segments()] - - def simplify(self, precision): - return self.segments(precision) - -# overwrite JSONEncoder for svg classes which have defined a .json() method -class JSONEncoder(json.JSONEncoder): - def default(self, obj): - if not isinstance(obj, tuple(svgClass.values() + [Svg])): - return json.JSONEncoder.default(self, obj) - - if not hasattr(obj, 'json'): - return repr(obj) - - return obj.json() - -## Code executed on module load ## - -# SVG tag handler classes are initialized here -# (classes must be defined before) -import inspect -svgClass = {} -# Register all classes with attribute 'tag' in svgClass dict -for name, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): - tag = getattr(cls, 'tag', None) - if tag: - svgClass[svg_ns + tag] = cls - diff --git a/svg2mod/svg2mod.py b/svg2mod/svg2mod.py index cc3f060..f6e63df 100755 --- a/svg2mod/svg2mod.py +++ b/svg2mod/svg2mod.py @@ -1,116 +1,43 @@ -#!/usr/bin/python +# Copyright (C) 2022 -- svg2mod developers < GitHub.com / svg2mod > -from __future__ import absolute_import - -import argparse -import datetime -import os -from pprint import pformat, pprint -import re -import svg2mod.svg as svg -import sys +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +''' +Helper classes to combine and calculate the points +from a svg object into a single continuous line +''' -#---------------------------------------------------------------------------- -DEFAULT_DPI = 96 # 96 as of Inkscape 0.92 - -def main(): - - args, parser = get_arguments() - - pretty = args.format == 'pretty' - use_mm = args.units == 'mm' - - if pretty: - - if not use_mm: - print( "Error: decimil units only allowed with legacy output type" ) - sys.exit( -1 ) - - #if args.include_reverse: - #print( - #"Warning: reverse footprint not supported or required for" + - #" pretty output format" - #) - - # Import the SVG: - imported = Svg2ModImport( - args.input_file_name, - args.module_name, - args.module_value - ) - - # Pick an output file name if none was provided: - if args.output_file_name is None: - - args.output_file_name = os.path.splitext( - os.path.basename( args.input_file_name ) - )[ 0 ] - - # Append the correct file name extension if needed: - if pretty: - extension = ".kicad_mod" - else: - extension = ".mod" - if args.output_file_name[ - len( extension ) : ] != extension: - args.output_file_name += extension - - # Create an exporter: - if pretty: - exported = Svg2ModExportPretty( - imported, - args.output_file_name, - args.scale_factor, - args.precision, - args.dpi, - ) - - else: - - # If the module file exists, try to read it: - exported = None - if os.path.isfile( args.output_file_name ): - - try: - exported = Svg2ModExportLegacyUpdater( - imported, - args.output_file_name, - args.scale_factor, - args.precision, - args.dpi, - include_reverse = not args.front_only, - ) - - except Exception as e: - raise e - #print( e.message ) - #exported = None - - # Write the module file: - if exported is None: - exported = Svg2ModExportLegacy( - imported, - args.output_file_name, - args.scale_factor, - args.precision, - use_mm = use_mm, - dpi = args.dpi, - include_reverse = not args.front_only, - ) - - # Export the footprint: - exported.write() +import copy +from typing import List, Tuple +from svg2mod import svg +from svg2mod.coloredlogger import logger #---------------------------------------------------------------------------- -class LineSegment( object ): +class LineSegment: + '''Kicad can only draw straight lines. + It is designed to have extra functions to help + calculate intersections. + ''' #------------------------------------------------------------------------ @staticmethod def _on_segment( p, q, r ): - """ Given three colinear points p, q, and r, check if + """ Given three collinear points p, q, and r, check if point q lies on line segment pr. """ if ( @@ -130,7 +57,7 @@ def _on_segment( p, q, r ): def _orientation( p, q, r ): """ Find orientation of ordered triplet (p, q, r). Returns following values - 0 --> p, q and r are colinear + 0 --> p, q and r are collinear 1 --> Clockwise 2 --> Counterclockwise """ @@ -144,6 +71,19 @@ def _orientation( p, q, r ): if val > 0: return 1 return 2 + #------------------------------------------------------------------------ + + @staticmethod + def vertical_intersection(p: svg.Point, q: svg.Point, r: float) -> svg.Point: + '''This is used for the in-lining algorithm + it finds a point on a line p -> q where x = r + ''' + if p.x == q.x: + return min([p,q], key=lambda v: v.y) + if r == p.x: return p + if r == q.x: return q + return svg.Point(r, (p.y-q.y)*(r-q.x)/(p.x-q.x)+q.y) + #------------------------------------------------------------------------ @@ -155,18 +95,29 @@ def __init__( self, p = None, q = None ): #------------------------------------------------------------------------ - def connects( self, segment ): + def connects( self, segment: 'LineSegment' ) -> bool: + ''' Return true if provided segment shares + endpoints with the current segment + ''' - if self.q.x == segment.p.x and self.q.y == segment.p.y: return True - if self.q.x == segment.q.x and self.q.y == segment.q.y: return True - if self.p.x == segment.p.x and self.p.y == segment.p.y: return True - if self.p.x == segment.q.x and self.p.y == segment.q.y: return True + if self.q == segment.p: return True + if self.q == segment.q: return True + if self.p == segment.p: return True + if self.p == segment.q: return True return False + #------------------------------------------------------------------------ + + def on_line(self, point: svg.Point) -> bool: + '''Returns true if the point is on the line. + Adapted from: + https://stackoverflow.com/questions/36487156/javascript-determine-if-a-point-resides-above-or-below-a-line-defined-by-two-poi + ''' + return not (self.p.x-self.q.x)*(point.y-self.q.y) - (self.p.y-self.q.y)*(point.x-self.q.x) #------------------------------------------------------------------------ - def intersects( self, segment ): + def intersects( self, segment: 'LineSegment' ) -> bool: """ Return true if line segments 'p1q1' and 'p2q2' intersect. Adapted from: http://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/ @@ -185,112 +136,146 @@ def intersects( self, segment ): or - # p1, q1 and p2 are colinear and p2 lies on segment p1q1: + # p1, q1 and p2 are collinear and p2 lies on segment p1q1: ( o1 == 0 and self._on_segment( self.p, segment.p, self.q ) ) or - # p1, q1 and p2 are colinear and q2 lies on segment p1q1: + # p1, q1 and p2 are collinear and q2 lies on segment p1q1: ( o2 == 0 and self._on_segment( self.p, segment.q, self.q ) ) or - # p2, q2 and p1 are colinear and p1 lies on segment p2q2: + # p2, q2 and p1 are collinear and p1 lies on segment p2q2: ( o3 == 0 and self._on_segment( segment.p, self.p, segment.q ) ) or - # p2, q2 and q1 are colinear and q1 lies on segment p2q2: + # p2, q2 and q1 are collinear and q1 lies on segment p2q2: ( o4 == 0 and self._on_segment( segment.p, self.q, segment.q ) ) ) #------------------------------------------------------------------------ - def q_next( self, q ): + def q_next( self, q:svg.Point ): + '''Shift segment endpoints so self.q is self.p + and q is the new self.q + ''' self.p = self.q self.q = q + #------------------------------------------------------------------------ + + def __eq__(self, other): + return ( + isinstance(other, LineSegment) and + other.p.x == self.p.x and other.p.y == self.p.y and + other.q.x == self.q.x and other.q.y == self.q.y + ) #------------------------------------------------------------------------ #---------------------------------------------------------------------------- -class PolygonSegment( object ): +class PolygonSegment: + ''' A polygon should be a collection of segments + creating an enclosed or manifold shape. + This class provides functionality to find overlap + points between a segment and it's self as well as + identify if another polygon rests inside of the + closed area of it's self. + + When initializing this class it will remove duplicate points in a row. + ''' #------------------------------------------------------------------------ - def __init__( self, points ): + def __init__( self, points:List): - self.points = points + self.points = [points[0]] - if len( points ) < 3: - print( - "Warning:" - " Path segment has only {} points (not a polygon?)".format( - len( points ) - ) - ) + for point in points: + if self.points[-1] != point: + self.points.append(point) - #------------------------------------------------------------------------ + self.bbox = None + self.calc_bbox() - # KiCad will not "pick up the pen" when moving between a polygon outline - # and holes within it, so we search for a pair of points connecting the - # outline (self) to the hole such that the connecting segment will not - # cross the visible inner space within any hole. - def _find_insertion_point( self, hole, holes ): - #print( " Finding insertion point. {} holes".format( len( holes ) ) ) + #------------------------------------------------------------------------ - # Try the next point on the container: - for cp in range( len( self.points ) ): - container_point = self.points[ cp ] + def _set_points(self, points: List[svg.Point]): + self.points = points[:] - #print( " Trying container point {}".format( cp ) ) + #------------------------------------------------------------------------ - # Try the next point on the hole: - for hp in range( len( hole.points ) - 1 ): - hole_point = hole.points[ hp ] + def _find_insertion_point( self, hole: 'PolygonSegment', holes: list, other_insertions: list ): + ''' KiCad will not "pick up the pen" when moving between a polygon outline + and holes within it, so we search for a pair of points connecting the + outline (self) or other previously inserted points to the hole such + that the connecting segment will not cross the visible inner space + within any hole. + ''' - #print( " Trying hole point {}".format( cp ) ) + highest_point = max(hole.points, key=lambda v: v.y) + vertical_line = LineSegment(highest_point, svg.Point(highest_point.x, self.bbox[1].y+1)) - bridge = LineSegment( container_point, hole_point ) + intersections = {self: self.intersects(vertical_line, False, count_intersections=True, get_points=True)} + for _,h,__ in other_insertions: + if h.bbox[0].x < highest_point.x and h.bbox[1].x > highest_point.x: + intersections[h] = h.intersects(vertical_line, False, count_intersections=True, get_points=True) - # Check for intersection with each other hole: - for other_hole in holes: + best = [self, intersections[self][0]] + best.append(LineSegment.vertical_intersection(best[1][0], best[1][1], highest_point.x)) + for path in intersections: + for p,q in intersections[path]: + pnt = LineSegment.vertical_intersection(p, q, highest_point.x) + if pnt.y < best[2].y: + best = [path, (p,q), pnt] - #print( " Trying other hole. Check = {}".format( hole == other_hole ) ) + if best[2] != best[1][0] and best[2] != best[1][1]: + p = best[0].points.index(best[1][0]) + p_cnt = best[0].points.count(best[1][0]) - # If the other hole intersects, don't bother checking - # remaining holes: - if other_hole.intersects( - bridge, - check_connects = ( - other_hole == hole or other_hole == self - ) - ): break + q = best[0].points.index(best[1][1]) + q_cnt = best[0].points.count(best[1][1]) - #print( " Hole does not intersect." ) + best_len = len(best[0].points) + tried = [[p],[q]] + # The same point can be present multiple times without being part of the + # desired segment. The points are also not next to each other. + while ( + (p_cnt > 1 or q_cnt > 1) and + (p + 1)%best_len != q and + (p - 1)%best_len != q + ): + if len(tried[0]) < p_cnt: + p = best[0].points.index(best[1][0], p+1) + tried[0].append(p) + elif len(tried[1]) < q_cnt: + p = tried[0][0] + tried[0] = [p] + q = best[0].points.index(best[1][1], q+1) + tried[1].append(q) else: - print( " Found insertion point: {}, {}".format( cp, hp ) ) - - # No other holes intersected, so this insertion point - # is acceptable: - return ( cp, hole.points_starting_on_index( hp ) ) + logger.error("Unable to find segment for inlining.") + break - print( - "Could not insert segment without overlapping other segments" - ) + ip = p if p < q else q + best[0]._set_points(best[0].points[:ip+1] + [best[2]] + best[0].points[ip+1:]) + return (best[2], hole, highest_point) #------------------------------------------------------------------------ - # Return the list of ordered points starting on the given index, ensuring - # that the first and last points are the same. - def points_starting_on_index( self, index ): + def points_starting_on_index( self, index: int ) -> List[svg.Point]: + ''' Return the list of ordered points starting on the given index, ensuring + that the first and last points are the same. + ''' points = self.points @@ -310,13 +295,15 @@ def points_starting_on_index( self, index ): #------------------------------------------------------------------------ - # Return a list of points with the given polygon segments (paths) inlined. - def inline( self, segments ): + def inline( self, segments: List[svg.Point] ) -> List[svg.Point]: + ''' Return a list of points with the given polygon segments (paths) inlined. ''' if len( segments ) < 1: return self.points - print( " Inlining {} segments...".format( len( segments ) ) ) + logger.debug( " Inlining {} segments...".format( len( segments ) ) ) + + segments.sort(reverse=True, key=lambda h: h.bbox[1].y) all_segments = segments[ : ] + [ self ] insertions = [] @@ -324,50 +311,55 @@ def inline( self, segments ): # Find the insertion point for each hole: for hole in segments: - insertion = self._find_insertion_point( - hole, all_segments - ) + insertion = self._find_insertion_point( hole, all_segments, insertions) + if insertion is not None: insertions.append( insertion ) - insertions.sort( key = lambda i: i[ 0 ] ) - - inlined = [ self.points[ 0 ] ] - ip = 1 - points = self.points + # Prevent returned points from affecting original object + points = copy.deepcopy(self.points) for insertion in insertions: - while ip <= insertion[ 0 ]: - inlined.append( points[ ip ] ) - ip += 1 + ip = points.index(insertion[0]) + hole = insertion[1].points_starting_on_index(insertion[1].points.index(insertion[2])) if ( - inlined[ -1 ].x == insertion[ 1 ][ 0 ].x and - inlined[ -1 ].y == insertion[ 1 ][ 0 ].y + points[ ip ].x == hole[ 0 ].x and + points[ ip ].y == hole[ 0 ].y ): - inlined += insertion[ 1 ][ 1 : -1 ] + # The point at the insertion point is duplicated so any action on that will affect both + points = points[:ip] + [copy.copy(points[ip])] + hole[ 1 : -1 ] + points[ip:] else: - inlined += insertion[ 1 ] + # The point at the insertion point is duplicated so any action on that will affect both + points = points[:ip] + [copy.copy(points[ip])] + hole + points[ip:] + + return points - inlined.append( svg.Point( - points[ ip - 1 ].x, - points[ ip - 1 ].y, - ) ) - while ip < len( points ): - inlined.append( points[ ip ] ) - ip += 1 + #------------------------------------------------------------------------ - return inlined + def intersects( self, line_segment: LineSegment, check_connects:bool , count_intersections=False, get_points=False): + '''Check to see if line_segment intersects with any + segments of the polygon. Default return True/False + If check_connects is True then it will skip intersections + that share endpoints with line_segment. - #------------------------------------------------------------------------ + count_intersections will return the number of intersections + with the polygon. - def intersects( self, line_segment, check_connects ): + get_points returns a tuple of the line that intersects + with line_segment. count_intersection in combination will + return a list of tuples of line segments. + ''' hole_segment = LineSegment() + intersections = 0 + intersect_segments = [] + virtual_line = LineSegment() + # Check each segment of other hole for intersection: for point in self.points: @@ -375,25 +367,54 @@ def intersects( self, line_segment, check_connects ): if hole_segment.p is not None: - if ( - check_connects and - line_segment.connects( hole_segment ) - ): continue + if ( check_connects and line_segment.connects( hole_segment )): + continue if line_segment.intersects( hole_segment ): - #print( "Intersection detected." ) - - return True - + if count_intersections: + if get_points: + intersect_segments.append((hole_segment.p, hole_segment.q)) + else: + # If a point is on the line segment we need to see if the + # simplified "virtual" line crosses the line segment. + + # Set the endpoints if they are of the line segment + if line_segment.on_line(hole_segment.q): + if not line_segment.on_line(hole_segment.p): + virtual_line.p = hole_segment.p + elif line_segment.on_line(hole_segment.p): + virtual_line.q = hole_segment.q + + # No points are on the line segment + else: + intersections += 1 + virtual_line = LineSegment() + + # The virtual line is complete check for intersections + if virtual_line.p and virtual_line.q: + if virtual_line.intersects(line_segment): + intersections += 1 + virtual_line = LineSegment() + + elif get_points: + return hole_segment.p, hole_segment.q + else: + return True + + if count_intersections: + return intersect_segments if get_points else intersections + if get_points and not check_connects: + return () return False #------------------------------------------------------------------------ - # Apply all transformations and rounding, then remove duplicate - # consecutive points along the path. - def process( self, transformer, flip ): + def process( self, transformer, flip, fill ): + ''' Apply all transformations, then remove duplicate + consecutive points along the path. + ''' points = [] for point in self.points: @@ -411,1044 +432,52 @@ def process( self, transformer, flip ): points[ 0 ].x != points[ -1 ].x or points[ 0 ].y != points[ -1 ].y ): - #print( "Warning: Closing polygon. start=({}, {}) end=({}, {})".format( - #points[ 0 ].x, points[ 0 ].y, - #points[ -1 ].x, points[ -1 ].y, - #) ) - - points.append( svg.Point( - points[ 0 ].x, - points[ 0 ].y, - ) ) - - #else: - #print( "Polygon closed: start=({}, {}) end=({}, {})".format( - #points[ 0 ].x, points[ 0 ].y, - #points[ -1 ].x, points[ -1 ].y, - #) ) - - self.points = points - - - #------------------------------------------------------------------------ - -#---------------------------------------------------------------------------- - -class Svg2ModImport( object ): - - #------------------------------------------------------------------------ - - def __init__( self, file_name, module_name, module_value ): - - self.file_name = file_name - self.module_name = module_name - self.module_value = module_value - - print( "Parsing SVG..." ) - self.svg = svg.parse( file_name ) - - - #------------------------------------------------------------------------ - -#---------------------------------------------------------------------------- - -class Svg2ModExport( object ): - - #------------------------------------------------------------------------ - - @staticmethod - def _convert_decimil_to_mm( decimil ): - return float( decimil ) * 0.00254 - - - #------------------------------------------------------------------------ - - @staticmethod - def _convert_mm_to_decimil( mm ): - return int( round( mm * 393.700787 ) ) - - - #------------------------------------------------------------------------ - - def _get_fill_stroke( self, item ): - - fill = True - stroke = True - stroke_width = 0.0 - - if item.style is not None and item.style != "": - - for property in item.style.split( ";" ): - - nv = property.split( ":" ); - name = nv[ 0 ].strip() - value = nv[ 1 ].strip() - - if name == "fill" and value == "none": - fill = False - - elif name == "stroke" and value == "none": - stroke = False - - elif name == "stroke-width": - value = value.replace( "px", "" ) - stroke_width = float( value ) * 25.4 / float(self.dpi) - - if not stroke: - stroke_width = 0.0 - elif stroke_width is None: - # Give a default stroke width? - stroke_width = self._convert_decimil_to_mm( 1 ) - - return fill, stroke, stroke_width - - - #------------------------------------------------------------------------ - - def __init__( - self, - svg2mod_import, - file_name, - scale_factor = 1.0, - precision = 20.0, - use_mm = True, - dpi = DEFAULT_DPI, - ): - if use_mm: - # 25.4 mm/in; - scale_factor *= 25.4 / float(dpi) - use_mm = True - else: - # PCBNew uses "decimil" (10K DPI); - scale_factor *= 10000.0 / float(dpi) - - self.imported = svg2mod_import - self.file_name = file_name - self.scale_factor = scale_factor - self.precision = precision - self.use_mm = use_mm - self.dpi = dpi - - #------------------------------------------------------------------------ - - def _calculate_translation( self ): - - min_point, max_point = self.imported.svg.bbox() - - # Center the drawing: - adjust_x = min_point.x + ( max_point.x - min_point.x ) / 2.0 - adjust_y = min_point.y + ( max_point.y - min_point.y ) / 2.0 - - self.translation = svg.Point( - 0.0 - adjust_x, - 0.0 - adjust_y, - ) - - - #------------------------------------------------------------------------ - - # Find and keep only the layers of interest. - def _prune( self, items = None ): - - if items is None: - - self.layers = {} - for name in self.layer_map.iterkeys(): - self.layers[ name ] = None - - items = self.imported.svg.items - self.imported.svg.items = [] - - for item in items: - - if not isinstance( item, svg.Group ): - continue - for name in self.layers.iterkeys(): - #if re.search( name, item.name, re.I ): - if name == item.name: - print( "Found SVG layer: {}".format( item.name ) ) - self.imported.svg.items.append( item ) - self.layers[ name ] = item - break - else: - self._prune( item.items ) - - - #------------------------------------------------------------------------ - - def _write_items( self, items, layer, flip = False ): - - for item in items: - - if isinstance( item, svg.Group ): - self._write_items( item.items, layer, flip ) - continue - - elif isinstance( item, svg.Path ): - - segments = [ - PolygonSegment( segment ) - for segment in item.segments( - precision = self.precision - ) - ] - - for segment in segments: - segment.process( self, flip ) - - if len( segments ) > 1: - points = segments[ 0 ].inline( segments[ 1 : ] ) - - elif len( segments ) > 0: - points = segments[ 0 ].points - - fill, stroke, stroke_width = self._get_fill_stroke( item ) - - if not self.use_mm: - stroke_width = self._convert_mm_to_decimil( - stroke_width - ) - - print( " Writing polygon with {} points".format( - len( points ) ) - ) - - self._write_polygon( - points, layer, fill, stroke, stroke_width - ) - - else: - print( "Unsupported SVG element: {}".format( - item.__class__.__name__ + if fill: + points.append( svg.Point( + points[ 0 ].x, + points[ 0 ].y, ) ) - - #------------------------------------------------------------------------ - - def _write_module( self, front ): - - module_name = self._get_module_name( front ) - - min_point, max_point = self.imported.svg.bbox() - min_point = self.transform_point( min_point, flip = False ) - max_point = self.transform_point( max_point, flip = False ) - - label_offset = 1200 - label_size = 600 - label_pen = 120 - - if self.use_mm: - label_size = self._convert_decimil_to_mm( label_size ) - label_pen = self._convert_decimil_to_mm( label_pen ) - reference_y = min_point.y - self._convert_decimil_to_mm( label_offset ) - value_y = max_point.y + self._convert_decimil_to_mm( label_offset ) - else: - reference_y = min_point.y - label_offset - value_y = max_point.y + label_offset - - self._write_module_header( - label_size, label_pen, - reference_y, value_y, - front, - ) - - for name, group in self.layers.iteritems(): - - if group is None: continue - - layer = self._get_layer_name( name, front ) - - #print( " Writing layer: {}".format( name ) ) - self._write_items( group.items, layer, not front ) - - self._write_module_footer( front ) - - - #------------------------------------------------------------------------ - - def _write_polygon_filled( self, points, layer, stroke_width = 0.0 ): - - self._write_polygon_header( points, layer ) - - for point in points: - self._write_polygon_point( point ) - - self._write_polygon_footer( layer, stroke_width ) - - - #------------------------------------------------------------------------ - - def _write_polygon_outline( self, points, layer, stroke_width ): - - prior_point = None - for point in points: - - if prior_point is not None: - - self._write_polygon_segment( - prior_point, point, layer, stroke_width - ) - - prior_point = point - - - #------------------------------------------------------------------------ - - def transform_point( self, point, flip = False ): - - transformed_point = svg.Point( - ( point.x + self.translation.x ) * self.scale_factor, - ( point.y + self.translation.y ) * self.scale_factor, - ) - - if flip: - transformed_point.x *= -1 - - if self.use_mm: - transformed_point.x = round( transformed_point.x, 12 ) - transformed_point.y = round( transformed_point.y, 12 ) - else: - transformed_point.x = int( round( transformed_point.x ) ) - transformed_point.y = int( round( transformed_point.y ) ) - - return transformed_point - - - #------------------------------------------------------------------------ - - def write( self ): - - self._prune() - - # Must come after pruning: - translation = self._calculate_translation() - - print( "Writing module file: {}".format( self.file_name ) ) - self.output_file = open( self.file_name, 'w' ) - - self._write_library_intro() - - self._write_modules() - - self.output_file.close() - self.output_file = None - - - #------------------------------------------------------------------------ - -#---------------------------------------------------------------------------- - -class Svg2ModExportLegacy( Svg2ModExport ): - - layer_map = { - #'inkscape-name' : [ kicad-front, kicad-back ], - 'Cu' : [ 15, 0 ], - 'Adhes' : [ 17, 16 ], - 'Paste' : [ 19, 18 ], - 'SilkS' : [ 21, 20 ], - 'Mask' : [ 23, 22 ], - 'Dwgs.User' : [ 24, 24 ], - 'Cmts.User' : [ 25, 25 ], - 'Eco1.User' : [ 26, 26 ], - 'Eco2.User' : [ 27, 27 ], - 'Edge.Cuts' : [ 28, 28 ], - } - - - #------------------------------------------------------------------------ - - def __init__( - self, - svg2mod_import, - file_name, - scale_factor = 1.0, - precision = 20.0, - use_mm = True, - dpi = DEFAULT_DPI, - include_reverse = True, - ): - super( Svg2ModExportLegacy, self ).__init__( - svg2mod_import, - file_name, - scale_factor, - precision, - use_mm, - dpi, - ) - - self.include_reverse = include_reverse - - - #------------------------------------------------------------------------ - - def _get_layer_name( self, name, front ): - - layer_info = self.layer_map[ name ] - layer = layer_info[ 0 ] - if not front and layer_info[ 1 ] is not None: - layer = layer_info[ 1 ] - - return layer - - - #------------------------------------------------------------------------ - - def _get_module_name( self, front = None ): - - if self.include_reverse and not front: - return self.imported.module_name + "-rev" - - return self.imported.module_name - - - #------------------------------------------------------------------------ - - def _write_library_intro( self ): - - modules_list = self._get_module_name( front = True ) - if self.include_reverse: - modules_list += ( - "\n" + - self._get_module_name( front = False ) - ) - - units = "" - if self.use_mm: - units = "\nUnits mm" - - self.output_file.write( """PCBNEW-LibModule-V1 {0}{1} -$INDEX -{2} -$EndINDEX -# -# {3} -# -""".format( - datetime.datetime.now().strftime( "%a %d %b %Y %I:%M:%S %p %Z" ), - units, - modules_list, - self.imported.file_name, -) - ) - - - #------------------------------------------------------------------------ - - def _write_module_header( - self, - label_size, - label_pen, - reference_y, - value_y, - front, - ): - - self.output_file.write( """$MODULE {0} -Po 0 0 0 {6} 00000000 00000000 ~~ -Li {0} -T0 0 {1} {2} {2} 0 {3} N I 21 "{0}" -T1 0 {5} {2} {2} 0 {3} N I 21 "{4}" -""".format( - self._get_module_name( front ), - reference_y, - label_size, - label_pen, - self.imported.module_value, - value_y, - 15, # Seems necessary -) - ) - - - #------------------------------------------------------------------------ - - def _write_module_footer( self, front ): - - self.output_file.write( - "$EndMODULE {0}\n".format( self._get_module_name( front ) ) - ) - - - #------------------------------------------------------------------------ - - def _write_modules( self ): - - self._write_module( front = True ) - - if self.include_reverse: - self._write_module( front = False ) - - self.output_file.write( "$EndLIBRARY" ) - - - #------------------------------------------------------------------------ - - def _write_polygon( self, points, layer, fill, stroke, stroke_width ): - - if fill: - self._write_polygon_filled( - points, layer - ) - - if stroke: - - self._write_polygon_outline( - points, layer, stroke_width - ) - - - #------------------------------------------------------------------------ - - def _write_polygon_footer( self, layer, stroke_width ): - - pass - - - #------------------------------------------------------------------------ - - def _write_polygon_header( self, points, layer ): - - pen = 1 - if self.use_mm: - pen = self._convert_decimil_to_mm( pen ) - - self.output_file.write( "DP 0 0 0 0 {} {} {}\n".format( - len( points ), - pen, - layer - ) ) - - - #------------------------------------------------------------------------ - - def _write_polygon_point( self, point ): - - self.output_file.write( - "Dl {} {}\n".format( point.x, point.y ) - ) - - - #------------------------------------------------------------------------ - - def _write_polygon_segment( self, p, q, layer, stroke_width ): - - self.output_file.write( "DS {} {} {} {} {} {}\n".format( - p.x, p.y, - q.x, q.y, - stroke_width, - layer - ) ) - - - #------------------------------------------------------------------------ - -#---------------------------------------------------------------------------- - -class Svg2ModExportLegacyUpdater( Svg2ModExportLegacy ): - - #------------------------------------------------------------------------ - - def __init__( - self, - svg2mod_import, - file_name, - scale_factor = 1.0, - precision = 20.0, - dpi = DEFAULT_DPI, - include_reverse = True, - ): - self.file_name = file_name - use_mm = self._parse_output_file() - - super( Svg2ModExportLegacyUpdater, self ).__init__( - svg2mod_import, - file_name, - scale_factor, - precision, - use_mm, - dpi, - include_reverse, - ) - - - #------------------------------------------------------------------------ - - def _parse_output_file( self ): - - print( "Parsing module file: {}".format( self.file_name ) ) - module_file = open( self.file_name, 'r' ) - lines = module_file.readlines() - module_file.close() - - self.loaded_modules = {} - self.post_index = [] - self.pre_index = [] - use_mm = False - - index = 0 - - # Find the start of the index: - while index < len( lines ): - - line = lines[ index ] - index += 1 - self.pre_index.append( line ) - if line[ : 6 ] == "$INDEX": - break - - m = re.match( "Units[\s]+mm[\s]*", line ) - if m is not None: - print( " Use mm detected" ) - use_mm = True - - # Read the index: - while index < len( lines ): - - line = lines[ index ] - if line[ : 9 ] == "$EndINDEX": - break - index += 1 - self.loaded_modules[ line.strip() ] = [] - - # Read up until the first module: - while index < len( lines ): - - line = lines[ index ] - if line[ : 7 ] == "$MODULE": - break - index += 1 - self.post_index.append( line ) - - # Read modules: - while index < len( lines ): - - line = lines[ index ] - if line[ : 7 ] == "$MODULE": - module_name, module_lines, index = self._read_module( lines, index ) - if module_name is not None: - self.loaded_modules[ module_name ] = module_lines - - elif line[ : 11 ] == "$EndLIBRARY": - break - - else: - raise Exception( - "Expected $EndLIBRARY: [{}]".format( line ) - ) - - #print( "Pre-index:" ) - #pprint( self.pre_index ) - - #print( "Post-index:" ) - #pprint( self.post_index ) - - #print( "Loaded modules:" ) - #pprint( self.loaded_modules ) - - return use_mm + self.points = points + self.calc_bbox() #------------------------------------------------------------------------ - def _read_module( self, lines, index ): - - # Read module name: - m = re.match( r'\$MODULE[\s]+([^\s]+)[\s]*', lines[ index ] ) - module_name = m.group( 1 ) - - print( " Reading module {}".format( module_name ) ) - - index += 1 - module_lines = [] - while index < len( lines ): - - line = lines[ index ] - index += 1 - - m = re.match( - r'\$EndMODULE[\s]+' + module_name + r'[\s]*', line - ) - if m is not None: - return module_name, module_lines, index - - module_lines.append( line ) - - raise Exception( - "Could not find end of module '{}'".format( module_name ) + def calc_bbox(self) -> Tuple[svg.Point, svg.Point]: + '''Calculate bounding box of self''' + self.bbox = ( + svg.Point(min(self.points, key=lambda v: v.x).x, min(self.points, key=lambda v: v.y).y), + svg.Point(max(self.points, key=lambda v: v.x).x, max(self.points, key=lambda v: v.y).y), ) - #------------------------------------------------------------------------ - def _write_library_intro( self ): - - # Write pre-index: - self.output_file.writelines( self.pre_index ) - - self.loaded_modules[ self._get_module_name( front = True ) ] = None - if self.include_reverse: - self.loaded_modules[ - self._get_module_name( front = False ) - ] = None - - # Write index: - for module_name in sorted( - self.loaded_modules.iterkeys(), - key = str.lower - ): - self.output_file.write( module_name + "\n" ) - - # Write post-index: - self.output_file.writelines( self.post_index ) - - - #------------------------------------------------------------------------ + def are_distinct(self, polygon): + ''' Checks if the supplied polygon either contains or insets our bounding box''' + distinct = True - def _write_preserved_modules( self, up_to = None ): + smaller = min([self, polygon], key=lambda p: svg.Segment(p.bbox[0], p.bbox[1]).length()) + larger = self if smaller == polygon else polygon - if up_to is not None: - up_to = up_to.lower() - - for module_name in sorted( - self.loaded_modules.iterkeys(), - key = str.lower + if ( + larger.bbox[0].x < smaller.bbox[0].x and + larger.bbox[0].y < smaller.bbox[0].y and + larger.bbox[1].x > smaller.bbox[1].x and + larger.bbox[1].y > smaller.bbox[1].y ): - if up_to is not None and module_name.lower() >= up_to: - continue - - module_lines = self.loaded_modules[ module_name ] - - if module_lines is not None: - - self.output_file.write( - "$MODULE {}\n".format( module_name ) - ) - self.output_file.writelines( module_lines ) - self.output_file.write( - "$EndMODULE {}\n".format( module_name ) - ) - - self.loaded_modules[ module_name ] = None - - - #------------------------------------------------------------------------ - - def _write_module_footer( self, front ): - - super( Svg2ModExportLegacyUpdater, self )._write_module_footer( - front, - ) - - # Write remaining modules: - if not front: - self._write_preserved_modules() - - - #------------------------------------------------------------------------ - - def _write_module_header( - self, - label_size, - label_pen, - reference_y, - value_y, - front, - ): - self._write_preserved_modules( - up_to = self._get_module_name( front ) - ) - - super( Svg2ModExportLegacyUpdater, self )._write_module_header( - label_size, - label_pen, - reference_y, - value_y, - front, - ) - - - #------------------------------------------------------------------------ - -#---------------------------------------------------------------------------- - -class Svg2ModExportPretty( Svg2ModExport ): - - layer_map = { - #'inkscape-name' : kicad-name, - 'Cu' : "{}.Cu", - 'Adhes' : "{}.Adhes", - 'Paste' : "{}.Paste", - 'SilkS' : "{}.SilkS", - 'Mask' : "{}.Mask", - 'CrtYd' : "{}.CrtYd", - 'Fab' : "{}.Fab", - 'Edge.Cuts' : "Edge.Cuts" - } - - - #------------------------------------------------------------------------ - - def _get_layer_name( self, name, front ): - - if front: - return self.layer_map[ name ].format("F") - else: - return self.layer_map[ name ].format("B") - - - #------------------------------------------------------------------------ - - def _get_module_name( self, front = None ): - - return self.imported.module_name - - - #------------------------------------------------------------------------ - - def _write_library_intro( self ): - - self.output_file.write( """(module {0} (layer F.Cu) (tedit {1:8X}) - (attr smd) - (descr "{2}") - (tags {3}) -""".format( - self.imported.module_name, #0 - int( round( os.path.getctime( #1 - self.imported.file_name - ) ) ), - "Imported from {}".format( self.imported.file_name ), #2 - "svg2mod", #3 -) - ) - - - #------------------------------------------------------------------------ - - def _write_module_footer( self, front ): - - self.output_file.write( "\n)" ) - - - #------------------------------------------------------------------------ - - def _write_module_header( - self, - label_size, - label_pen, - reference_y, - value_y, - front, - ): - if front: - side = "F" - else: - side = "B" - - self.output_file.write( -""" (fp_text reference {0} (at 0 {1}) (layer {2}.SilkS) hide - (effects (font (size {3} {3}) (thickness {4}))) - ) - (fp_text value {5} (at 0 {6}) (layer {2}.SilkS) hide - (effects (font (size {3} {3}) (thickness {4}))) - )""".format( - - self._get_module_name(), #0 - reference_y, #1 - side, #2 - label_size, #3 - label_pen, #4 - self.imported.module_value, #5 - value_y, #6 -) - ) - - - #------------------------------------------------------------------------ - - def _write_modules( self ): - - self._write_module( front = True ) - - - #------------------------------------------------------------------------ - - def _write_polygon( self, points, layer, fill, stroke, stroke_width ): - - if fill: - self._write_polygon_filled( - points, layer, stroke_width - ) - - # Polygons with a fill and stroke are drawn with the filled polygon - # above: - if stroke and not fill: - - self._write_polygon_outline( - points, layer, stroke_width - ) + distinct = False + # Check number of horizontal intersections. If the number is odd then it the smaller polygon + # is contained. If the number is even then the polygon is outside of the larger polygon + if not distinct: + test_line = LineSegment(smaller.points[0], svg.Point(larger.bbox[1].x+1, smaller.points[0].y)) + distinct = bool((larger.intersects(test_line, False, True) + 1)%2) + return distinct #------------------------------------------------------------------------ - def _write_polygon_footer( self, layer, stroke_width ): - - self.output_file.write( - " )\n (layer {})\n (width {})\n )".format( - layer, stroke_width - ) - ) - - - #------------------------------------------------------------------------ - - def _write_polygon_header( self, points, layer ): - - self.output_file.write( "\n (fp_poly\n (pts \n" ) - - - #------------------------------------------------------------------------ - - def _write_polygon_point( self, point ): - - self.output_file.write( - " (xy {} {})\n".format( point.x, point.y ) - ) - - - #------------------------------------------------------------------------ - - def _write_polygon_segment( self, p, q, layer, stroke_width ): - - self.output_file.write( - """\n (fp_line - (start {} {}) - (end {} {}) - (layer {}) - (width {}) - )""".format( - p.x, p.y, - q.x, q.y, - layer, - stroke_width, -) - ) - - - #------------------------------------------------------------------------ #---------------------------------------------------------------------------- -def get_arguments(): - - parser = argparse.ArgumentParser( - description = ( - 'Convert Inkscape SVG drawings to KiCad footprint modules.' - ) - ) - - #------------------------------------------------------------------------ - - parser.add_argument( - '-i', '--input-file', - type = str, - dest = 'input_file_name', - metavar = 'FILENAME', - help = "name of the SVG file", - required = True, - ) - - parser.add_argument( - '-o', '--output-file', - type = str, - dest = 'output_file_name', - metavar = 'FILENAME', - help = "name of the module file", - ) - - parser.add_argument( - '--name', '--module-name', - type = str, - dest = 'module_name', - metavar = 'NAME', - help = "base name of the module", - default = "svg2mod", - ) - - parser.add_argument( - '--value', '--module-value', - type = str, - dest = 'module_value', - metavar = 'VALUE', - help = "value of the module", - default = "G***", - ) - - parser.add_argument( - '-f', '--factor', - type = float, - dest = 'scale_factor', - metavar = 'FACTOR', - help = "scale paths by this factor", - default = 1.0, - ) - - parser.add_argument( - '-p', '--precision', - type = float, - dest = 'precision', - metavar = 'PRECISION', - help = "smoothness for approximating curves with line segments (float)", - default = 10.0, - ) - - parser.add_argument( - '--front-only', - dest = 'front_only', - action = 'store_const', - const = True, - help = "omit output of back module (legacy output format)", - default = False, - ) - - parser.add_argument( - '--format', - type = str, - dest = 'format', - metavar = 'FORMAT', - choices = [ 'legacy', 'pretty' ], - help = "output module file format (legacy|pretty)", - default = 'pretty', - ) - - parser.add_argument( - '--units', - type = str, - dest = 'units', - metavar = 'UNITS', - choices = [ 'decimil', 'mm' ], - help = "output units, if output format is legacy (decimil|mm)", - default = 'mm', - ) - - parser.add_argument( - '-d', '--dpi', - type = int, - dest = 'dpi', - metavar = 'DPI', - help = "DPI of the SVG file (int)", - default = DEFAULT_DPI, - ) - - return parser.parse_args(), parser - - - #------------------------------------------------------------------------ - -#---------------------------------------------------------------------------- - -main() - - -#---------------------------------------------------------------------------- -# vi: set et sts=4 sw=4 ts=4: