• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Starou / SimpleIDML / 16986331745

15 Aug 2025 08:35AM UTC coverage: 90.301% (+0.3%) from 90.042%
16986331745

push

github

web-flow
Merge pull request #79 from Starou/upgrade_vagrantbox_debian12

Upgrade vagrantbox debian12

1741 of 1928 relevant lines covered (90.3%)

1.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.86
/src/simple_idml/components.py
1
# -*- coding: utf-8 -*-
2

3
import copy
2✔
4
import datetime
2✔
5
import os
2✔
6
import re
2✔
7
from decimal import Decimal
2✔
8
from lxml import etree
2✔
9
from simple_idml import IdPkgNS, BACKINGSTORY
2✔
10
from simple_idml.utils import increment_xmltag_id, prefix_content_filename, deepcopy_element_as
2✔
11
from simple_idml.utils import Proxy
2✔
12

13
RECTO = "recto"
2✔
14
VERSO = "verso"
2✔
15

16
rx_node_name_from_xml_name = re.compile(r"[\w]+/[\w]+_([\w]+)\.xml")
2✔
17

18

19
class IDMLXMLFile():
2✔
20
    """Abstract class for various XML files found in IDML Packages. """
21
    name = None
2✔
22
    doctype = None
2✔
23
    excluded_tags_for_prefix = (
2✔
24
        "Document",
25
        "Language",
26
        "NumberingList",
27
        "NamedGrid",
28
        "TextVariable",
29
        "Section",
30
        "DocumentUser",
31
        "CrossReferenceFormat",
32
        "BuildingBlock",
33
        "IndexingSortOption",
34
        "ABullet",
35
        "Assignment",
36
        "XMLTag",
37
        "MasterSpread",
38
    )
39
    prefixable_attrs = (
2✔
40
        "Self",
41
        "XMLContent",
42
        "ParentStory",
43
        "MappedStyle",
44
        "AppliedCharacterStyle",
45
        "AppliedParagraphStyle",
46
        "AppliedObjectStyle",
47
        "NextStyle",
48
        "FillColor",
49
        "StrokeColor",
50
        "ItemLayer",
51
        "NextTextFrame",
52
        "PreviousTextFrame",
53
    )
54
    prefixable_content_tags = (
2✔
55
        'ParagraphShadingColor',
56
        'ParagraphBorderColor',
57
    )
58

59
    def __init__(self, idml_package, working_copy_path=None):
2✔
60
        self.idml_package = idml_package
2✔
61
        self.working_copy_path = working_copy_path
2✔
62
        self._fobj = None
2✔
63
        self._dom = None
2✔
64

65
    def __repr__(self):
2✔
66
        return f"<{self.__class__.__name__} object {self.name} at {hex(id(self))}>"
2✔
67

68
    @property
2✔
69
    def fobj(self):
2✔
70
        if self._fobj is None:
2✔
71
            if self.working_copy_path:
2✔
72
                filename = os.path.join(self.working_copy_path, self.name)
2✔
73
                fobj = open(filename, mode="rb+")
2✔
74
            else:
75
                fobj = self.idml_package.open(self.name, mode="r")
2✔
76
            self._fobj = fobj
2✔
77
        return self._fobj
2✔
78

79
    @property
2✔
80
    def dom(self):
2✔
81
        if self._dom is None:
2✔
82
            xml = self.fobj.read()
2✔
83
            try:
2✔
84
                dom = etree.fromstring(xml, parser=etree.XMLParser(huge_tree=True))
2✔
85
            except ValueError:
2✔
86
                # Python3: when the fobj come from Story.create()
87
                # it is strictly a textfile that cannot be implicitly
88
                # read as a bytestring (required by etree.fromstring()).
89
                dom = etree.fromstring(xml.encode('utf-8'))
2✔
90
            self._dom = dom
2✔
91
            self._fobj.close()
2✔
92
            self._fobj = None
2✔
93
        return self._dom
2✔
94

95
    def tostring(self):
2✔
96
        kwargs = {"xml_declaration": True,
2✔
97
                  "encoding": "UTF-8",
98
                  "standalone": True,
99
                  "pretty_print": True}
100

101
        if etree.LXML_VERSION < (2, 3):
2✔
102
            strn = etree.tostring(self.dom, **kwargs)
×
103
            if self.doctype:
×
104
                lines = strn.splitlines()
×
105
                lines.insert(1, self.doctype)
×
106
                strn = "\n".join(line.decode("utf-8") for line in lines)
×
107
                strn += "\n"
×
108
                strn = strn.encode("utf-8")
×
109
        else:
110
            kwargs["doctype"] = self.doctype
2✔
111
            strn = etree.tostring(self.dom, **kwargs)
2✔
112
        return strn
2✔
113

114
    def synchronize(self):
2✔
115
        # Explicit initialization of dom from self._fobj before reset
116
        # because in tostring() we get the dom from this file if None.
117
        self.dom  # pylint: disable=pointless-statement
2✔
118
        self.fobj.close()
2✔
119
        self._fobj = None
2✔
120

121
        # Must instanciate with a working_copy to use this.
122
        with open(os.path.join(self.working_copy_path, self.name), mode="wb+") as fobj:
2✔
123
            fobj.write(self.tostring())
2✔
124

125
    def get_element_by_id(self, value, tag="XMLElement", attr="Self"):
2✔
126
        elem = self.dom.xpath(f"//{tag}[@{attr}='{value}']")
2✔
127
        # etree FutureWarning when trying to simply do: elem = len(elem) and elem[0] or None
128
        if len(elem):
2✔
129
            elem = elem[0]
2✔
130
            if elem.tag == "XMLElement":
2✔
131
                elem = XMLElement(elem)
2✔
132
        else:
133
            elem = None
2✔
134
        return elem
2✔
135

136
    def prefix_references(self, prefix):
2✔
137
        """Update references inside various XML files found in an IDML package
138
           after a call to prefix()."""
139

140
        # <XMLElement Self="di2i3" MarkupTag="XMLTag/article" XMLContent="u102"/>
141
        # <[Spread|Page|...] Self="ub6" FlattenerOverride="Default"
142
        # <[TextFrame|...] Self="uca" ParentStory="u102" ...>
143
        # <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"
144
        #  PointSize="10" />
145
        for elt in self.dom.iter():
2✔
146
            if elt.tag in self.excluded_tags_for_prefix:
2✔
147
                continue
2✔
148
            if elt.tag in self.prefixable_content_tags and elt.text:
2✔
149
                elt.text = f"{prefix}{elt.text}"
×
150
            for attr in self.prefixable_attrs:
2✔
151
                if elt.get(attr):
2✔
152
                    if attr in ['NextTextFrame', 'PreviousTextFrame'] and elt.get(attr) == 'n':
2✔
153
                        continue
2✔
154
                    elt.set(attr, f"{prefix}{elt.get(attr)}")
2✔
155

156
        # <idPkg:Spread src="Spreads/Spread_ub6.xml"/>
157
        # <idPkg:Story src="Stories/Story_u139.xml"/>
158
        for elt in self.dom.xpath(".//idPkg:Spread | .//idPkg:Story",
2✔
159
                                  namespaces={'idPkg': IdPkgNS}):
160
            if elt.get("src"):
2✔
161
                elt.set("src", prefix_content_filename(elt.get("src"), prefix, "ref"))
2✔
162

163
        # <Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"...
164
        # StoryList="ue4 u102 u11b u139 u9c"...>
165
        elt = self.dom.xpath("/Document")
2✔
166
        if elt and elt[0].get("StoryList"):
2✔
167
            elt[0].set("StoryList", " ".join([f"{prefix}{s}" for s in elt[0].get("StoryList").split(" ")]))
2✔
168

169
    def set_element_resource_path(self, element_id, resource_path, synchronize=False):
2✔
170
        """ For Spread and Story subclasses only (this comment is a call for a Mixin). """
171
        # the element may not be an <XMLElement> (so tag="*").
172
        elt = self.get_element_by_id(element_id, tag="*")
2✔
173
        if elt is None:
2✔
174
            return
2✔
175
        link = elt.find("Link")
2✔
176
        if link is not None:
2✔
177
            link.set("LinkResourceURI", resource_path)
2✔
178
            if synchronize:
2✔
179
                self.synchronize()
2✔
180

181
    def remove_xml_element_page_items(self, element_id, synchronize=False):
2✔
182
        """Page items are sometimes in Stories rather in Spread. """
183
        elt = self.get_element_by_id(element_id)
2✔
184
        if elt.get("NoTextMarker"):
2✔
185
            elt.attrib.pop("NoTextMarker")
×
186
        if elt.get("XMLContent"):
2✔
187
            elt.attrib.pop("XMLContent")
2✔
188
        for child in elt.iterchildren():
2✔
189
            elt.remove(child)
2✔
190
        if synchronize:
2✔
191
            self.synchronize()
×
192

193

194
class MasterSpread(IDMLXMLFile):
2✔
195
    def __init__(self, idml_package, name, working_copy_path=None):
2✔
196
        super().__init__(idml_package, working_copy_path)
2✔
197
        self.name = name
2✔
198

199

200
class Spread(IDMLXMLFile):
2✔
201
    """
202

203
        Spread coordinates system
204
        -------------------------
205

206
                        _ -Y
207
       _________________|__________________
208
      |                 |                  |
209
      |                 |                  |
210
      |                 |                  |
211
      |                 |                  |
212
      |                 |                  |
213
   -X |                 | (0,0)            | +X
214
   |--|-----------------+-------------------->
215
      |                 |                  |
216
      |                 |                  |
217
      |                 |                  |
218
      |                 |                  |
219
      |                 |                  |
220
      |                 |                  |
221
      |_________________|__________________|
222
                        |
223
                        ˇ +Y
224
    """
225

226
    def __init__(self, idml_package, name, working_copy_path=None):
2✔
227
        super().__init__(idml_package, working_copy_path)
2✔
228
        self.name = name
2✔
229
        self._pages = None
2✔
230
        self._node = None
2✔
231

232
    @property
2✔
233
    def pages(self):
2✔
234
        if self._pages is None:
2✔
235
            pages = [Page(self, node) for node in self.dom.findall("Spread/Page")]
2✔
236
            self._pages = pages
2✔
237
        return self._pages
2✔
238

239
    @property
2✔
240
    def node(self):
2✔
241
        if self._node is None:
2✔
242
            node = self.dom.find("Spread")
2✔
243
            self._node = node
2✔
244
        return self._node
2✔
245

246
    def add_page(self, page):
2✔
247
        """ Spread only manage 2 pages. """
248
        if self.pages:
2✔
249
            # the last page is also the first (and only) one here and is a verso (front).
250
            face_required = RECTO
2✔
251
            last_page = self.pages[-1]
2✔
252
            last_page.node.addnext(copy.deepcopy(page.node))
2✔
253
        else:
254
            face_required = VERSO
2✔
255
            self.node.append(copy.deepcopy(page.node))
2✔
256
        # TODO: attributes (layer, masterSpread, ...)
257
        for item in page.page_items:
2✔
258
            self.node.append(copy.deepcopy(item))
2✔
259
        self._pages = None
2✔
260

261
        # Correct the position of the new page in the Spread.
262
        last_page = self.pages[-1]
2✔
263

264
        # At this level, because the last_page may not be in a correct position
265
        # into the Spread, a call to last_page.page_items may also return
266
        # the page items of the other page of the Spread.
267
        # So we force the setting from the inserted page and use those references
268
        # to move the items if the face has to be changed.
269
        items_references = [item.get("Self") for item in page.page_items]
2✔
270
        last_page.page_items = [item for item in last_page.page_items
2✔
271
                                if item.get("Self") in items_references]
272
        last_page.set_face(face_required)
2✔
273

274
    def clear(self):
2✔
275
        items = list(self.node.items())
2✔
276
        self.node.clear()
2✔
277
        for k, value in items:
2✔
278
            self.node.set(k, value)
2✔
279

280
        self._pages = None
2✔
281

282
    def get_node_name_from_xml_name(self):
2✔
283
        return rx_node_name_from_xml_name.match(self.name).groups()[0]
2✔
284

285
    def set_layer_references(self, layer_id):
2✔
286
        for elt in self.dom.iter():
2✔
287
            if elt.get("ItemLayer"):
2✔
288
                elt.set("ItemLayer", layer_id)
2✔
289
        self.synchronize()
2✔
290

291
    def has_any_item_on_layer(self, layer_id):
2✔
292
        # The page Guide are not page items.
293
        return bool(len(self.node.xpath(f".//*[not(self::Guide)][@ItemLayer='{layer_id}']")))
2✔
294

295
    def has_any_guide_on_layer(self, layer_id):
2✔
296
        return bool(len(self.node.xpath(f".//Guide[@ItemLayer='{layer_id}']")))
2✔
297

298
    def remove_guides_on_layer(self, layer_id, synchronize=False):
2✔
299
        for guide in self.node.xpath(f".//Guide[@ItemLayer='{layer_id}']"):
2✔
300
            guide.getparent().remove(guide)
2✔
301
        if synchronize:
2✔
302
            self.synchronize()
×
303

304
    def remove_page_item(self, item_id, synchronize=False):
2✔
305
        # etree FutureWarning when trying to simply do: elt = foo() or bar().
306
        elt = self.get_element_by_id(item_id, tag="*")
2✔
307
        if elt is None:
2✔
308
            elt = self.get_element_by_id(item_id, tag="*", attr="ParentStory")
×
309
        elt.getparent().remove(elt)
2✔
310
        if synchronize:
2✔
311
            self.synchronize()
2✔
312

313
    def rectangle_to_textframe(self, rectangle):
2✔
314
        textframe = deepcopy_element_as(rectangle, "TextFrame")
2✔
315
        textframe.set("ContentType", "TextType")
2✔
316
        textframe.set("PreviousTextFrame", "n")
2✔
317
        textframe.set("NextTextFrame", "n")
2✔
318
        # These attributes and subelements must be removed.
319
        del textframe.attrib["StoryTitle"]  # Suppose it is always present.
2✔
320
        for sub_elt_name in ("InCopyExportOption", "FrameFittingOption", "ObjectExportOption"):
2✔
321
            try:
2✔
322
                textframe.remove(textframe.find(sub_elt_name))
2✔
323
            # There is not such subelement.
324
            except TypeError:
×
325
                pass
×
326
        rectangle.addnext(textframe)
2✔
327
        self.node.remove(rectangle)
2✔
328

329

330
STORIES_DIRNAME = "Stories"
2✔
331

332

333
class Story(IDMLXMLFile):
2✔
334
    def __init__(self, idml_package, name, working_copy_path=None):
2✔
335
        super().__init__(idml_package, working_copy_path)
2✔
336
        self.name = name
2✔
337
        self.node_name = "Story"
2✔
338
        self._node = None
2✔
339

340
    @classmethod
2✔
341
    def create(cls, idml_package, story_id, xml_element_id, xml_element_tag, working_copy_path):
2✔
342
        dirname = os.path.join(working_copy_path, STORIES_DIRNAME)
2✔
343
        if not os.path.exists(dirname):
2✔
344
            os.mkdir(dirname)
2✔
345
        story_name = f"{STORIES_DIRNAME}/Story_{story_id}.xml"
2✔
346
        story = Story(idml_package, story_name, working_copy_path)
2✔
347

348
        # Difficult to do it in .fobj() because we don't always need
349
        # to create a unexisting file.
350
        filename = os.path.join(working_copy_path, story_name)
2✔
351
        story._fobj = open(filename, mode="w+")
2✔
352
        story.fobj.write(
2✔
353
            f"""<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
354
   <idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="7.5">
355
     <Story Self="{story_id}" AppliedTOCStyle="n" TrackChanges="false" StoryTitle="$ID/" AppliedNamedGrid="n">
356
       <StoryPreference OpticalMarginAlignment="false" OpticalMarginSize="12" FrameType="TextFrameType" StoryOrientation="Horizontal" StoryDirection="LeftToRightDirection"/>
357
       <InCopyExportOption IncludeGraphicProxies="true" IncludeAllResources="false"/>
358
       <XMLElement Self="{xml_element_id}" MarkupTag="XMLTag/{xml_element_tag}" XMLContent="{story_id}" />
359
     </Story>
360
</idPkg:Story>
361
""")
362

363
        story.fobj.close()
2✔
364
        story._fobj = None
2✔
365
        return story
2✔
366

367
    @property
2✔
368
    def node(self):
2✔
369
        if self._node is None:
2✔
370
            node = self.dom.find(self.node_name)
2✔
371
            self._node = node
2✔
372
        return self._node
2✔
373

374
    def set_element_attributes(self, element_id, attrs):
2✔
375
        element = self.get_element_by_id(element_id)
2✔
376
        element.set_attributes(attrs)
2✔
377

378
    def set_element_content(self, element_id, content):
2✔
379
        self.clear_element_content(element_id)
2✔
380
        xml_element = self.get_element_by_id(element_id)
2✔
381
        xml_element.set_content(content)
2✔
382
        self._fix_siblings_style(xml_element)
2✔
383

384
    def _fix_siblings_style(self, xml_element):
2✔
385
        """Fix ticket #11 when importing XML."""
386
        # Get the sibling elts that need to be styled.
387
        siblings = []
2✔
388
        for elt in xml_element.itersiblings():
2✔
389
            if elt.tag not in ["Content", "Br"]:
2✔
390
                break
2✔
391
            siblings.append(elt)
×
392

393
        if not len(siblings):
2✔
394
            return
2✔
395

396
        # The parent style is locally applied.
397
        local_style = xml_element.clone_style_range()
×
398
        for sibling in siblings:
×
399
            local_style.append(sibling)
×
400
        xml_element.addnext(local_style)
×
401

402
    def clear_element_content(self, element_id):
2✔
403
        element = self.get_element_by_id(element_id)
2✔
404
        # We remove all `CharacterStyleRange' containers except the first.
405
        # FIXME: This should handle ./ParagraphStyleRange/CharacterStyleRange too.
406
        children = element.xpath("./CharacterStyleRange")[1:]
2✔
407
        for child in children:
2✔
408
            element.remove(child)
2✔
409
        for content_node in self.get_element_content_nodes(element):
2✔
410
            content_node.text = ""
2✔
411

412
    def get_element_content_nodes(self, element):
2✔
413
        return element.xpath(("./ParagraphStyleRange/CharacterStyleRange/Content | "
2✔
414
                              "./CharacterStyleRange/Content | "
415
                              "./XMLElement/CharacterStyleRange/Content | "
416
                              "./Content"))
417

418
    def get_element_content_and_xmlelement_nodes(self, element):
2✔
419
        return element.xpath(("./ParagraphStyleRange/CharacterStyleRange/Content | "
2✔
420
                              "./CharacterStyleRange/Content | "
421
                              "./ParagraphStyleRange/CharacterStyleRange/XMLElement | "
422
                              "./CharacterStyleRange/XMLElement | "
423
                              "./ParagraphStyleRange/XMLElement | "
424
                              "./XMLElement | "
425
                              "./Content"))
426

427
    def set_element_id(self, element):
2✔
428
        ref_element = list(element.itersiblings(tag="XMLElement", preceding=True))
2✔
429
        if ref_element:
2✔
430
            ref_element = ref_element[0]
2✔
431
            position = "sibling"
2✔
432
        else:
433
            ref_element = list(element.iterancestors(tag="XMLElement"))
2✔
434
            if ref_element:
2✔
435
                ref_element = ref_element[0]
2✔
436
            else:
437
                raise NotImplementedError
×
438
            position = "child"
2✔
439
        element.set("Self", increment_xmltag_id(ref_element.get("Self"), position))
2✔
440

441
    def remove_element(self, element_id, synchronize=False):
2✔
442
        elt = self.get_element_by_id(element_id).element
2✔
443
        elt.getparent().remove(elt)
2✔
444
        if synchronize:
2✔
445
            self.synchronize()
2✔
446

447
    def remove_children(self, element_id, keep_style=False, synchronize=False):
2✔
448
        elt = self.get_element_by_id(element_id).element
2✔
449

450
        for i, child in enumerate(elt.iterchildren()):
2✔
451
            if i == 0 and keep_style and child.tag in ['ParagraphStyleRange', 'CharacterStyleRange']:
2✔
452
                continue
2✔
453
            elt.remove(child)
2✔
454
        if synchronize:
2✔
455
            self.synchronize()
2✔
456

457
    def add_element(self, element_destination_id, element):
2✔
458
        node = self.get_element_by_id(element_destination_id)
2✔
459
        node.append(element)
2✔
460
        self.set_element_id(element)
2✔
461

462
    def add_content_to_element(self, element_id, content, parent=None):
2✔
463
        element = self.get_element_by_id(element_id)
2✔
464
        xml_element = XMLElement(element=element)
2✔
465
        xml_element.add_content(content, parent)
2✔
466

467
    def add_note(self, element_id, note, author, when=None):
2✔
468
        element = self.get_element_by_id(element_id)
2✔
469
        when = when or datetime.datetime.now().replace(microsecond=0)
2✔
470
        when = when.isoformat()
2✔
471
        note_node = etree.fromstring(f"""<Note Collapsed="false" CreationDate="{when}" ModificationDate="{when}" UserName="{author}">
2✔
472
                                            <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]">
473
                                                <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">
474
                                                    <Content>{note}</Content>
475
                                                </CharacterStyleRange>
476
                                            </ParagraphStyleRange>
477
                                        </Note>""")
478
        element.insert(0, note_node)
2✔
479

480

481
class BackingStory(Story):
2✔
482
    def __init__(self, idml_package, name=BACKINGSTORY, working_copy_path=None):
2✔
483
        super().__init__(idml_package, name, working_copy_path)
2✔
484
        self.node_name = "XmlStory"
2✔
485

486
    def get_root(self):
2✔
487
        return XMLElement(self.dom.find("*//XMLElement"))
2✔
488

489

490
class Designmap(IDMLXMLFile):
2✔
491
    name = "designmap.xml"
2✔
492
    doctype = '<?aid style="50" type="document" readerVersion="6.0" featureSet="257" product="7.5(142)" ?>'
2✔
493
    page_start_attr = "PageStart"
2✔
494

495
    def __init__(self, idml_package, working_copy_path):
2✔
496
        super().__init__(idml_package, working_copy_path)
2✔
497
        self._spread_nodes = None
2✔
498
        self._style_mapping_node = None
2✔
499
        self._section_node = None
2✔
500
        self._layer_nodes = None
2✔
501
        self._active_layer = None
2✔
502

503
    @property
2✔
504
    def spread_nodes(self):
2✔
505
        if self._spread_nodes is None:
2✔
506
            nodes = self.dom.findall("idPkg:Spread", namespaces={'idPkg': IdPkgNS})
2✔
507
            self._spread_nodes = nodes
2✔
508
        return self._spread_nodes
2✔
509

510
    @property
2✔
511
    def layer_nodes(self):
2✔
512
        if self._layer_nodes is None:
2✔
513
            nodes = self.dom.findall("Layer")
2✔
514
            self._layer_nodes = nodes
2✔
515
        return self._layer_nodes
2✔
516

517
    @property
2✔
518
    def active_layer(self):
2✔
519
        if self._active_layer is None:
2✔
520
            active_layer = self.dom.get("ActiveLayer")
2✔
521
            self._active_layer = active_layer
2✔
522
        return self._active_layer
2✔
523

524
    @active_layer.setter
2✔
525
    def active_layer(self, layer):
2✔
526
        self.dom.set("ActiveLayer", layer)
2✔
527
        self._active_layer = layer
2✔
528

529
    @active_layer.deleter
2✔
530
    def active_layer(self):
2✔
531
        if self.dom.get("ActiveLayer"):
2✔
532
            del self.dom.attrib["ActiveLayer"]
2✔
533
        self._active_layer = None
2✔
534

535
    @property
2✔
536
    def section_node(self):
2✔
537
        if self._section_node is None:
2✔
538
            nodes = self.dom.find("Section")
2✔
539
            self._section_node = nodes
2✔
540
        return self._section_node
2✔
541

542
    @property
2✔
543
    def style_mapping_node(self):
2✔
544
        """<idPkg:Mapping src="XML/Mapping.xml"/>"""
545
        if self._style_mapping_node is None:
2✔
546
            node = self.dom.find("idPkg:Mapping", namespaces={'idPkg': IdPkgNS})
2✔
547
            self._style_mapping_node = node
2✔
548
        return self._style_mapping_node
2✔
549

550
    def set_style_mapping_node(self):
2✔
551
        """Do it only if self.style_mapping_node is None."""
552
        self.dom.append(
2✔
553
            etree.Element("{%s}Mapping" % IdPkgNS, src=StyleMapping.name)
554
        )
555

556
    def add_spread(self, spread):
2✔
557
        if self.spread_nodes:
2✔
558
            self.spread_nodes[-1].addnext(
2✔
559
                etree.Element("{%s}Spread" % IdPkgNS, src=spread.name)
560
            )
561

562
    def prefix(self, prefix):
2✔
563
        self.prefix_active_layer(prefix)
2✔
564
        self.prefix_page_start(prefix)
2✔
565

566
    def prefix_active_layer(self, prefix):
2✔
567
        self.active_layer = f"{prefix}{self.active_layer}"
2✔
568

569
    def prefix_page_start(self, prefix):
2✔
570
        section_node = self.section_node
2✔
571
        current_page_start = section_node.get(self.page_start_attr)
2✔
572
        section_node.set(self.page_start_attr, f"{prefix}{current_page_start}")
2✔
573

574
    def add_stories(self, stories):
2✔
575
        # Add stories in StoryList.
576
        elt = self.dom.xpath("/Document")[0]
2✔
577
        current_stories = elt.get("StoryList").split(" ")
2✔
578
        elt.set("StoryList", " ".join(current_stories + stories))
2✔
579

580
        # Add <idPkg:Story src="Stories/Story_[name].xml"/> elements.
581
        for story in stories:
2✔
582
            elt.append(etree.Element("{http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging}Story",
2✔
583
                                     src=f"Stories/Story_{story}.xml"))
584

585
    def add_layer_nodes(self, layer_nodes):
2✔
586
        current_layers_ids = [layer.get("Self") for layer in self.layer_nodes]
2✔
587
        for layer in reversed(layer_nodes):
2✔
588
            # If a similar layer is already present, we do not add it.
589
            if layer.get("Self") not in current_layers_ids:
2✔
590
                self.layer_nodes[-1].addnext(copy.deepcopy(layer))
2✔
591
        self._layer_nodes = None
2✔
592

593
    def remove_layer(self, layer_id, synchronize=False):
2✔
594
        layer = self.get_element_by_id(layer_id, tag="Layer", attr="Self")
2✔
595
        layer.getparent().remove(layer)
2✔
596
        self._layer_nodes = None
2✔
597
        if self.active_layer == layer_id:
2✔
598
            del self.active_layer
2✔
599
            # Change the active layer if some remains.
600
            if len(self.layer_nodes):
2✔
601
                self.active_layer = self.layer_nodes[0].get("Self")
2✔
602
        if synchronize:
2✔
603
            self.synchronize()
2✔
604

605
    def suffix_layers(self, suffix):
2✔
606
        for layer in self.layer_nodes:
2✔
607
            layer.set("Name", f"{layer.get('Name')}{suffix}")
2✔
608

609
    def merge_layers(self, with_name=None):
2✔
610
        layer_0 = self.layer_nodes.pop(0)
2✔
611
        if with_name:
2✔
612
            layer_0.set("Name", with_name)
2✔
613
        for layer in self.layer_nodes:
2✔
614
            layer.getparent().remove(layer)
2✔
615
        self._layer_nodes = None
2✔
616
        self.active_layer = layer_0.get("Self")
2✔
617
        self.synchronize()
2✔
618

619
    def get_layer_id_by_name(self, layer_name):
2✔
620
        layer_node = self.dom.xpath(f".//Layer[@Name='{layer_name}']")[0]
2✔
621
        return layer_node.get("Self")
2✔
622

623
    def get_active_layer_name(self):
2✔
624
        layer_node = self.dom.xpath(f".//Layer[@Self='{self.active_layer}']")[0]
2✔
625
        return layer_node.get("Name")
2✔
626

627

628
class Style(IDMLXMLFile):
2✔
629
    name = "Resources/Styles.xml"
2✔
630

631
    def get_style_node_by_name(self, style_name):
2✔
632
        return self.dom.xpath(f".//CharacterStyle[@Self='{style_name}']")[0]
2✔
633

634
    def style_groups(self):
2✔
635
        """ Groups are `RootCharacterStyleGroup', `RootParagraphStyleGroup' etc. """
636
        return [elt for elt in self.dom.xpath("/idPkg:Styles/*", namespaces={'idPkg': IdPkgNS})
2✔
637
                if re.match(r"^.+Group$", elt.tag)]
638

639
    def get_root(self):
2✔
640
        return self.dom.xpath("/idPkg:Styles", namespaces={'idPkg': IdPkgNS})[0]
2✔
641

642

643
class StyleMapping(IDMLXMLFile):
2✔
644
    name = "XML/Mapping.xml"
2✔
645
    initial_dom = ("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
2✔
646
                   <idPkg:Mapping xmlns:idPkg=\"http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging\"\
647
                   DOMVersion=\"7.5\">\
648
                   </idPkg:Mapping>")
649

650
    def __init__(self, idml_package, working_copy_path=None):
2✔
651
        super().__init__(idml_package, working_copy_path)
2✔
652
        self._character_style_mapping = None
2✔
653

654
    @property
2✔
655
    def fobj(self):
2✔
656
        """Overriden because it may not exists in the package. """
657
        try:
2✔
658
            super().fobj
2✔
659
        except (KeyError, IOError):
2✔
660
            if self.working_copy_path:
2✔
661
                self._initialize_fobj()
2✔
662
        return self._fobj
2✔
663

664
    @property
2✔
665
    def dom(self):
2✔
666
        """Overriden because it may not exists in the package. """
667
        try:
2✔
668
            super().dom
2✔
669
        except AttributeError:
2✔
670
            self._dom = etree.fromstring(self.initial_dom.encode("utf-8"))
2✔
671
        return self._dom
2✔
672

673
    @property
2✔
674
    def character_style_mapping(self):
2✔
675
        if self._character_style_mapping is None:
2✔
676
            mapping = {}
2✔
677
            for node in self.iter_stylenode():
2✔
678
                tag = node.get("MarkupTag").replace("XMLTag/", "")
2✔
679
                style = node.get("MappedStyle")
2✔
680
                mapping[tag] = style
2✔
681
            self._character_style_mapping = mapping
2✔
682
        return self._character_style_mapping
2✔
683

684
    def _initialize_fobj(self):
2✔
685
        filename = os.path.join(self.working_copy_path, self.name)
2✔
686
        fobj = open(filename, mode="w+")
2✔
687
        fobj.write(self.initial_dom)
2✔
688
        fobj.seek(0)
2✔
689
        self._fobj = fobj
2✔
690

691
    def iter_stylenode(self):
2✔
692
        for node in self.dom.xpath("//XMLImportMap"):
2✔
693
            yield node
2✔
694

695
    def add_stylenode(self, node):
2✔
696
        self.dom.append(copy.deepcopy(node))
2✔
697
        self._character_style_mapping = None
2✔
698

699

700
class Graphic(IDMLXMLFile):
2✔
701
    name = "Resources/Graphic.xml"
2✔
702

703

704
class Preferences(IDMLXMLFile):
2✔
705
    name = "Resources/Preferences.xml"
2✔
706

707

708
class Tags(IDMLXMLFile):
2✔
709
    name = "XML/Tags.xml"
2✔
710

711
    def tags(self):
2✔
712
        return self.dom.xpath("//XMLTag")
2✔
713

714
    def get_root(self):
2✔
715
        return self.dom.xpath("/idPkg:Tags", namespaces={'idPkg': IdPkgNS})[0]
2✔
716

717

718
class Fonts(IDMLXMLFile):
2✔
719
    name = "Resources/Fonts.xml"
2✔
720

721
    def fonts(self):
2✔
722
        return self.dom.xpath("//FontFamily")
2✔
723

724
    def get_root(self):
2✔
725
        return self.dom.xpath("/idPkg:Fonts", namespaces={'idPkg': IdPkgNS})[0]
2✔
726

727

728
class Page():
2✔
729
    """
730
        Coordinate system
731
        -----------------
732

733
        The <Page> position in the <Spread> is expressed by 2 attributes :
734

735
            - `GeometricBounds' (y1 x1 y2 x2) where (x1, y1) is the position of the upper-left
736
                corner of the page in the Spread coordinates system *before* transformation
737
                and (x2, y2) is the position of the lower-right corner.
738
            - `ItemTransform' (a b c d x y) where x and y are the translation applied to the
739
                Page into the Spread.
740
    """
741

742
    def __init__(self, spread, node):
2✔
743
        self.spread = spread
2✔
744
        self.node = node
2✔
745
        self._page_items = None
2✔
746
        self._coordinates = None
2✔
747
        self._is_recto = None
2✔
748

749
    @property
2✔
750
    def page_items(self):
2✔
751
        if self._page_items is None:
2✔
752
            page_items = [i for i in self.node.itersiblings()
2✔
753
                          if not i.tag == "Page" and self.page_item_is_in_self(i)]
754
            self._page_items = page_items
2✔
755
        return self._page_items
2✔
756

757
    @page_items.setter
2✔
758
    def page_items(self, items):
2✔
759
        self._page_items = items
2✔
760

761
    @property
2✔
762
    def is_recto(self):
2✔
763
        if self._is_recto is None:
2✔
764
            is_recto = False
2✔
765
            if self.coordinates["x1"] >= Decimal("0"):
2✔
766
                is_recto = True
2✔
767
            self._is_recto = is_recto
2✔
768
        return self._is_recto
2✔
769

770
    @property
2✔
771
    def face(self):
2✔
772
        if self.is_recto:
2✔
773
            return RECTO
2✔
774
        return VERSO
2✔
775

776
    @property
2✔
777
    def geometric_bounds(self):
2✔
778
        return [Decimal(c) for c in self.node.get("GeometricBounds").split(" ")]
2✔
779

780
    @geometric_bounds.setter
2✔
781
    def geometric_bounds(self, matrix):
2✔
782
        self.node.set("GeometricBounds", " ".join([str(v) for v in matrix]))
2✔
783

784
    @property
2✔
785
    def item_transform(self):
2✔
786
        return [Decimal(c) for c in self.node.get("ItemTransform").split(" ")]
2✔
787

788
    @item_transform.setter
2✔
789
    def item_transform(self, matrix):
2✔
790
        self.node.set("ItemTransform", " ".join([str(v) for v in matrix]))
2✔
791

792
    @property
2✔
793
    def coordinates(self):
2✔
794
        if self._coordinates is None:
2✔
795
            geometric_bounds = self.geometric_bounds
2✔
796
            item_transform = self.item_transform
2✔
797
            coordinates = {
2✔
798
                "x1": geometric_bounds[1] + item_transform[4],
799
                "y1": geometric_bounds[0] + item_transform[5],
800
                "x2": geometric_bounds[3] + item_transform[4],
801
                "y2": geometric_bounds[2] + item_transform[5],
802
            }
803
            self._coordinates = coordinates
2✔
804
        return self._coordinates
2✔
805

806
    def page_item_is_in_self(self, page_item):
2✔
807
        """The rule is «If the first `PathPointType' is in the page so is the page item.»
808

809
            A PathPointType is in the page if its X is in the X-axis range of the page
810
            because we assume that 2 pages (or more) of the same Spread are Y-aligned.
811
            There is not any D&D reference here.
812
        """
813

814
        item_transform = [Decimal(c) for c in page_item.get("ItemTransform").split(" ")]
2✔
815
        #TODO factoriser "Properties/PathGeometry/GeometryPathType/PathPointArray/PathPointType"
816
        point = page_item.xpath("Properties/PathGeometry/GeometryPathType/PathPointArray/PathPointType")[0]
2✔
817
        x, y = [Decimal(c) for c in point.get("Anchor").split(" ")]
2✔
818
        x = x + item_transform[4]
2✔
819
        y = y + item_transform[5]
2✔
820

821
        if x >= self.coordinates["x1"] and x <= self.coordinates["x2"]:
2✔
822
            return True
2✔
823
        else:
824
            return False
2✔
825

826
    def set_face(self, face):
2✔
827
        if self.face == face:
2✔
828
            return
2✔
829
        item_transform = self.item_transform
2✔
830
        item_transform_x_origin = item_transform[4]
2✔
831

832
        if face == RECTO:
2✔
833
            item_transform[4] = Decimal("0")
2✔
834
        elif face == VERSO:
2✔
835
            item_transform[4] = - self.geometric_bounds[3]
2✔
836

837
        item_transform_x = item_transform[4] - item_transform_x_origin
2✔
838
        self.item_transform = item_transform
2✔
839

840
        # All page items are moved according to item_transform_x.
841
        for item in self.page_items:
2✔
842
            item_transform = [Decimal(c) for c in item.get("ItemTransform").split(" ")]
2✔
843
            item_transform[4] = item_transform[4] + item_transform_x
2✔
844
            item.set("ItemTransform", " ".join([str(v) for v in item_transform]))
2✔
845

846
        self._is_recto = None
2✔
847
        self._coordinates = None
2✔
848

849

850
class XMLElement(Proxy):
2✔
851
    """A proxy over the etree.Element to represent XMLElement nodes in Story files. """
852
    def __repr__(self):
2✔
853
        if self.element is not None:
2✔
854
            return "%s {%s}" % (repr(self.element), ", ".join(["%s: %s" % (k, v) for k, v in self.element.items()]))
2✔
855
        return "XMLElement (no element)"
×
856

857
    def __init__(self, element=None, tag=None):
2✔
858
        if element is not None:
2✔
859
            self.element = element
2✔
860
        else:
861
            self.element = etree.Element("XMLElement", MarkupTag=f"XMLTag/{tag}")
2✔
862
        super().__init__(target=self.element)
2✔
863

864
    def add_content(self, content, parent=None, style_range_node=None):
2✔
865
        content_element = etree.Element("Content")
2✔
866
        content_element.text = content
2✔
867
        if style_range_node is None:
2✔
868
            style_range_node = parent.clone_style_range()
2✔
869
        style_range_node.append(content_element)
2✔
870
        self.element.append(style_range_node)
2✔
871

872
    def set_content(self, content):
2✔
873
        try:
2✔
874
            self.get_element_content_nodes()[0].text = content
2✔
875
        except IndexError:
2✔
876
            return
2✔
877
        # Ticket #8 - Fix the style locally.
878
        if self.get_local_character_style_range() is None and \
2✔
879
           self.get_super_character_style_range() is not None:
880
            local_style = self.clone_style_range()
×
881
            self.apply_style_locally(local_style)
×
882

883
    def apply_style_locally(self, style_range_node):
2✔
884
        """The content node of self is moved into style_range_node. """
885
        content_node = self.get_element_content_nodes()[0]
×
886
        style_range_node.append(content_node)
×
887
        self.append(style_range_node)
×
888

889
    def clone_style_range(self):
2✔
890
        style_node = self.get_character_style_range()
2✔
891
        applied_style = style_node.get("AppliedCharacterStyle")
2✔
892
        style_range_node = etree.Element("CharacterStyleRange", AppliedCharacterStyle=applied_style)
2✔
893
        properties_node = etree.SubElement(style_range_node, "Properties")
2✔
894

895
        attrs = [
2✔
896
            "PointSize",
897
            "FontStyle",
898
            "HorizontalScale",
899
            "Tracking",
900
            "FillColor",
901
            "FillTint",
902
            "Capitalization",
903
            "PointSize",
904
            "StrokeWeight",
905
            "MiterLimit",
906
            "RubyFontSize",
907
            "KentenFontSize",
908
            "DiacriticPosition",
909
            "Ligatures",
910
            "OTFContextualAlternate",
911
            "BaselineShift",
912
            "ParagraphShadingColor",
913
            "ParagraphBorderColor",
914
        ]
915

916
        for attr in attrs:
2✔
917
            if style_node.get(attr) is not None:
2✔
918
                style_range_node.set(attr, style_node.get(attr))
2✔
919

920
        for attr in ("Leading", "AppliedFont", "ParagraphShadingColor", "ParagraphBorderColor"):
2✔
921
            path = f"Properties/{attr}"
2✔
922
            attr_node = style_node.find(path)
2✔
923
            if attr_node is not None:
2✔
924
                properties_node.append(copy.deepcopy(attr_node))
2✔
925
        return style_range_node
2✔
926

927
    def get_attribute(self, name):
2✔
928
        attr_node = self._get_attribute_node(name)
2✔
929
        return attr_node.get("Value") if attr_node is not None else None
2✔
930

931
    def _get_attribute_node(self, name):
2✔
932
        attr_node = self.xpath(f"./XMLAttribute[@Name='{name}']")
2✔
933
        if len(attr_node):
2✔
934
            return attr_node[0]
2✔
935

936
    def get_attributes(self):
2✔
937
        return {node.get("Name"): node.get("Value") for node in self.xpath("./XMLAttribute")}
2✔
938

939
    def set_attribute(self, name, value):
2✔
940
        attr_node = self._get_attribute_node(name)
2✔
941
        if attr_node is None:
2✔
942
            attr_node = etree.Element("XMLAttribute", Name=name, Self=f"{self.get('Self')}XMLAttributen{name}")
2✔
943
            self.append(attr_node)
2✔
944
        attr_node.set("Value", value)
2✔
945

946
    def set_attributes(self, attributes):
2✔
947
        for name, value in attributes.items():
2✔
948
            self.set_attribute(name, value)
2✔
949

950
    def get_character_style_range(self):
2✔
951
        """The applied style may be contained or the container. """
952
        node = self.get_local_character_style_range()
2✔
953
        if node is None:
2✔
954
            node = self.get_super_character_style_range()
2✔
955
        return node
2✔
956

957
    def get_local_character_style_range(self):
2✔
958
        try:
2✔
959
            node = self.xpath(("./ParagraphStyleRange/CharacterStyleRange | ./CharacterStyleRange"))[0]
2✔
960
        except (IndexError, AttributeError):
2✔
961
            node = None
2✔
962
        return node
2✔
963

964
    def get_super_character_style_range(self):
2✔
965
        node = self.getparent()
2✔
966
        if node.tag != "CharacterStyleRange":
2✔
967
            node = None
×
968
        return node
2✔
969

970
    # TODO: factorize with Story.get_element_content_nodes().
971
    def get_element_content_nodes(self):
2✔
972
        return self.xpath(("./ParagraphStyleRange/CharacterStyleRange/Content | "
2✔
973
                           "./CharacterStyleRange/Content | "
974
                           "./XMLElement/CharacterStyleRange/Content | "
975
                           "./Content"))
976

977
    def to_xml_structure_element(self):
2✔
978
        """Return the node as seen in the Structure panel of InDesign. """
979
        attrs = dict(self.attrib)
2✔
980
        name = attrs.pop("MarkupTag").replace("XMLTag/", "")
2✔
981
        return etree.Element(name, **attrs)
2✔
982

983

984
def get_idml_xml_file_by_name(idml_package, name, working_copy_path=None):
2✔
985
    kwargs = {"idml_package": idml_package, "name": name, "working_copy_path": working_copy_path}
2✔
986
    dirname, basename = os.path.split(name)
2✔
987
    if basename == "designmap.xml":
2✔
988
        kwargs.pop("name")
2✔
989
        klass = Designmap
2✔
990
    elif dirname == "MasterSpreads":
2✔
991
        klass = MasterSpread
2✔
992
    elif dirname == "Spreads":
2✔
993
        klass = Spread
2✔
994
    elif dirname == "Stories":
2✔
995
        klass = Story
2✔
996
    elif name == "XML/BackingStory.xml":
2✔
997
        kwargs.pop("name")
2✔
998
        klass = BackingStory
2✔
999
    elif name == "Resources/Fonts.xml":
2✔
1000
        kwargs.pop("name")
2✔
1001
        klass = Fonts
2✔
1002
    elif name == "Resources/Graphic.xml":
2✔
1003
        kwargs.pop("name")
2✔
1004
        klass = Graphic
2✔
1005
    elif name == "Resources/Preferences.xml":
2✔
1006
        kwargs.pop("name")
2✔
1007
        klass = Preferences
2✔
1008
    elif name == "Resources/Styles.xml":
2✔
1009
        kwargs.pop("name")
2✔
1010
        klass = Style
2✔
1011
    elif name == "XML/Tags.xml":
2✔
1012
        kwargs.pop("name")
2✔
1013
        klass = Tags
2✔
1014
    elif name == "XML/Mapping.xml":
2✔
1015
        kwargs.pop("name")
2✔
1016
        klass = StyleMapping
2✔
1017

1018
    return klass(**kwargs)
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc