ixbrl-viewer 1.4.1__py3-none-any.whl → 1.4.86__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. iXBRLViewerPlugin/__init__.py +231 -127
  2. iXBRLViewerPlugin/_version.py +33 -3
  3. iXBRLViewerPlugin/constants.py +96 -2
  4. iXBRLViewerPlugin/featureConfig.py +8 -1
  5. iXBRLViewerPlugin/iXBRLViewer.py +356 -214
  6. iXBRLViewerPlugin/plugin.py +12 -0
  7. iXBRLViewerPlugin/ui.py +81 -50
  8. iXBRLViewerPlugin/viewer/dist/ixbrlviewer.js +1 -1
  9. iXBRLViewerPlugin/viewer/dist/ixbrlviewer.js.LICENSE.txt +12 -5
  10. iXBRLViewerPlugin/viewer/i18next-parser.config.js +1 -1
  11. iXBRLViewerPlugin/viewer/src/data/utr.json +1244 -0
  12. iXBRLViewerPlugin/viewer/src/html/fact-details.html +69 -38
  13. iXBRLViewerPlugin/viewer/src/html/footer-logo.html +4 -0
  14. iXBRLViewerPlugin/viewer/src/html/footnote-details.html +2 -2
  15. iXBRLViewerPlugin/viewer/src/html/inspector.html +352 -197
  16. iXBRLViewerPlugin/viewer/src/i18n/cy/balancetypes.json +1 -0
  17. iXBRLViewerPlugin/viewer/src/i18n/cy/currencies.json +13 -0
  18. iXBRLViewerPlugin/viewer/src/i18n/cy/datatypes.json +9 -0
  19. iXBRLViewerPlugin/viewer/src/i18n/cy/labelroles.json +24 -0
  20. iXBRLViewerPlugin/viewer/src/i18n/cy/referenceparts.json +10 -0
  21. iXBRLViewerPlugin/viewer/src/i18n/cy/scale.json +16 -0
  22. iXBRLViewerPlugin/viewer/src/i18n/cy/tooltips.json +17 -0
  23. iXBRLViewerPlugin/viewer/src/i18n/cy/translation.json +179 -0
  24. iXBRLViewerPlugin/viewer/src/i18n/da/balancetypes.json +4 -0
  25. iXBRLViewerPlugin/viewer/src/i18n/da/currencies.json +13 -0
  26. iXBRLViewerPlugin/viewer/src/i18n/da/datatypes.json +9 -0
  27. iXBRLViewerPlugin/viewer/src/i18n/da/labelroles.json +24 -0
  28. iXBRLViewerPlugin/viewer/src/i18n/da/referenceparts.json +10 -0
  29. iXBRLViewerPlugin/viewer/src/i18n/da/scale.json +15 -0
  30. iXBRLViewerPlugin/viewer/src/i18n/da/tooltips.json +17 -0
  31. iXBRLViewerPlugin/viewer/src/i18n/da/translation.json +179 -0
  32. iXBRLViewerPlugin/viewer/src/i18n/de/balancetypes.json +4 -0
  33. iXBRLViewerPlugin/viewer/src/i18n/de/currencies.json +13 -0
  34. iXBRLViewerPlugin/viewer/src/i18n/de/datatypes.json +9 -0
  35. iXBRLViewerPlugin/viewer/src/i18n/de/labelroles.json +24 -0
  36. iXBRLViewerPlugin/viewer/src/i18n/de/referenceparts.json +10 -0
  37. iXBRLViewerPlugin/viewer/src/i18n/de/scale.json +15 -0
  38. iXBRLViewerPlugin/viewer/src/i18n/de/tooltips.json +17 -0
  39. iXBRLViewerPlugin/viewer/src/i18n/de/translation.json +179 -0
  40. iXBRLViewerPlugin/viewer/src/i18n/en/balancetypes.json +4 -0
  41. iXBRLViewerPlugin/viewer/src/i18n/en/datatypes.json +10 -0
  42. iXBRLViewerPlugin/viewer/src/i18n/en/labelroles.json +4 -0
  43. iXBRLViewerPlugin/viewer/src/i18n/en/scale.json +16 -0
  44. iXBRLViewerPlugin/viewer/src/i18n/en/tooltips.json +17 -0
  45. iXBRLViewerPlugin/viewer/src/i18n/en/translation.json +73 -23
  46. iXBRLViewerPlugin/viewer/src/i18n/es/balancetypes.json +4 -0
  47. iXBRLViewerPlugin/viewer/src/i18n/es/datatypes.json +10 -0
  48. iXBRLViewerPlugin/viewer/src/i18n/es/labelroles.json +24 -0
  49. iXBRLViewerPlugin/viewer/src/i18n/es/scale.json +16 -0
  50. iXBRLViewerPlugin/viewer/src/i18n/es/tooltips.json +17 -0
  51. iXBRLViewerPlugin/viewer/src/i18n/es/translation.json +87 -37
  52. iXBRLViewerPlugin/viewer/src/i18n/fr/balancetypes.json +4 -0
  53. iXBRLViewerPlugin/viewer/src/i18n/fr/currencies.json +13 -0
  54. iXBRLViewerPlugin/viewer/src/i18n/fr/datatypes.json +9 -0
  55. iXBRLViewerPlugin/viewer/src/i18n/fr/labelroles.json +24 -0
  56. iXBRLViewerPlugin/viewer/src/i18n/fr/referenceparts.json +10 -0
  57. iXBRLViewerPlugin/viewer/src/i18n/fr/scale.json +15 -0
  58. iXBRLViewerPlugin/viewer/src/i18n/fr/tooltips.json +17 -0
  59. iXBRLViewerPlugin/viewer/src/i18n/fr/translation.json +179 -0
  60. iXBRLViewerPlugin/viewer/src/i18n/nl/balancetypes.json +4 -0
  61. iXBRLViewerPlugin/viewer/src/i18n/nl/currencies.json +13 -0
  62. iXBRLViewerPlugin/viewer/src/i18n/nl/datatypes.json +9 -0
  63. iXBRLViewerPlugin/viewer/src/i18n/nl/labelroles.json +24 -0
  64. iXBRLViewerPlugin/viewer/src/i18n/nl/referenceparts.json +10 -0
  65. iXBRLViewerPlugin/viewer/src/i18n/nl/scale.json +15 -0
  66. iXBRLViewerPlugin/viewer/src/i18n/nl/tooltips.json +17 -0
  67. iXBRLViewerPlugin/viewer/src/i18n/nl/translation.json +179 -0
  68. iXBRLViewerPlugin/viewer/src/i18n/uk/balancetypes.json +4 -0
  69. iXBRLViewerPlugin/viewer/src/i18n/uk/currencies.json +13 -0
  70. iXBRLViewerPlugin/viewer/src/i18n/uk/datatypes.json +9 -0
  71. iXBRLViewerPlugin/viewer/src/i18n/uk/labelroles.json +24 -0
  72. iXBRLViewerPlugin/viewer/src/i18n/uk/referenceparts.json +10 -0
  73. iXBRLViewerPlugin/viewer/src/i18n/uk/scale.json +15 -0
  74. iXBRLViewerPlugin/viewer/src/i18n/uk/tooltips.json +17 -0
  75. iXBRLViewerPlugin/viewer/src/i18n/uk/translation.json +179 -0
  76. iXBRLViewerPlugin/viewer/src/icons/calculator.svg +13 -0
  77. iXBRLViewerPlugin/viewer/src/icons/circle-cross.svg +11 -0
  78. iXBRLViewerPlugin/viewer/src/icons/circle-tick.svg +11 -0
  79. iXBRLViewerPlugin/viewer/src/icons/dark-mode.svg +4 -0
  80. iXBRLViewerPlugin/viewer/src/icons/dimension.svg +1 -5
  81. iXBRLViewerPlugin/viewer/src/icons/member.svg +2 -5
  82. iXBRLViewerPlugin/viewer/src/icons/multi-tag.svg +10 -0
  83. iXBRLViewerPlugin/viewer/src/img/arelle-dark.svg +179 -0
  84. iXBRLViewerPlugin/viewer/src/img/inline-viewer-dark.svg +59 -0
  85. iXBRLViewerPlugin/viewer/src/js/accordian.js +5 -4
  86. iXBRLViewerPlugin/viewer/src/js/aspect.js +29 -10
  87. iXBRLViewerPlugin/viewer/src/js/aspect.test.js +40 -31
  88. iXBRLViewerPlugin/viewer/src/js/balance.js +14 -0
  89. iXBRLViewerPlugin/viewer/src/js/calculation.js +213 -0
  90. iXBRLViewerPlugin/viewer/src/js/calculation.test.js +306 -0
  91. iXBRLViewerPlugin/viewer/src/js/calculationInspector.js +187 -0
  92. iXBRLViewerPlugin/viewer/src/js/chart.js +26 -24
  93. iXBRLViewerPlugin/viewer/src/js/chart.test.js +10 -9
  94. iXBRLViewerPlugin/viewer/src/js/concept.js +37 -4
  95. iXBRLViewerPlugin/viewer/src/js/concept.test.js +30 -6
  96. iXBRLViewerPlugin/viewer/src/js/datatype.js +20 -0
  97. iXBRLViewerPlugin/viewer/src/js/datatype.test.js +62 -0
  98. iXBRLViewerPlugin/viewer/src/js/dialog.js +6 -4
  99. iXBRLViewerPlugin/viewer/src/js/docOrderIndex.js +7 -7
  100. iXBRLViewerPlugin/viewer/src/js/fact.js +156 -59
  101. iXBRLViewerPlugin/viewer/src/js/fact.test.js +160 -29
  102. iXBRLViewerPlugin/viewer/src/js/factset.js +64 -15
  103. iXBRLViewerPlugin/viewer/src/js/factset.test.js +102 -31
  104. iXBRLViewerPlugin/viewer/src/js/footnote.js +8 -2
  105. iXBRLViewerPlugin/viewer/src/js/index.js +11 -3
  106. iXBRLViewerPlugin/viewer/src/js/inspector.js +747 -221
  107. iXBRLViewerPlugin/viewer/src/js/inspector.test.js +143 -25
  108. iXBRLViewerPlugin/viewer/src/js/interval.js +70 -0
  109. iXBRLViewerPlugin/viewer/src/js/interval.test.js +153 -0
  110. iXBRLViewerPlugin/viewer/src/js/ixbrlviewer.js +391 -262
  111. iXBRLViewerPlugin/viewer/src/js/ixbrlviewer.test.js +134 -20
  112. iXBRLViewerPlugin/viewer/src/js/ixnode.js +1 -1
  113. iXBRLViewerPlugin/viewer/src/js/menu.js +25 -7
  114. iXBRLViewerPlugin/viewer/src/js/number-matcher.js +7 -3
  115. iXBRLViewerPlugin/viewer/src/js/number-matcher.test.js +4 -0
  116. iXBRLViewerPlugin/viewer/src/js/outline.js +34 -13
  117. iXBRLViewerPlugin/viewer/src/js/outline.test.js +97 -91
  118. iXBRLViewerPlugin/viewer/src/js/period.js +0 -1
  119. iXBRLViewerPlugin/viewer/src/js/report.js +260 -351
  120. iXBRLViewerPlugin/viewer/src/js/report.test.js +95 -27
  121. iXBRLViewerPlugin/viewer/src/js/reportset.js +264 -0
  122. iXBRLViewerPlugin/viewer/src/js/reportset.test.js +357 -0
  123. iXBRLViewerPlugin/viewer/src/js/search.js +72 -38
  124. iXBRLViewerPlugin/viewer/src/js/search.test.js +184 -84
  125. iXBRLViewerPlugin/viewer/src/js/summary.js +34 -8
  126. iXBRLViewerPlugin/viewer/src/js/summary.test.js +69 -25
  127. iXBRLViewerPlugin/viewer/src/js/tableExport.js +9 -9
  128. iXBRLViewerPlugin/viewer/src/js/taxonomynamer.js +34 -0
  129. iXBRLViewerPlugin/viewer/src/js/taxonomynamer.test.js +32 -0
  130. iXBRLViewerPlugin/viewer/src/js/test-utils.js +46 -0
  131. iXBRLViewerPlugin/viewer/src/js/theme.js +50 -0
  132. iXBRLViewerPlugin/viewer/src/js/unit.js +90 -32
  133. iXBRLViewerPlugin/viewer/src/js/unit.test.js +62 -25
  134. iXBRLViewerPlugin/viewer/src/js/util.js +94 -0
  135. iXBRLViewerPlugin/viewer/src/js/util.test.js +33 -1
  136. iXBRLViewerPlugin/viewer/src/js/utr.js +27 -0
  137. iXBRLViewerPlugin/viewer/src/js/viewer.js +205 -181
  138. iXBRLViewerPlugin/viewer/src/js/viewerOptions.js +0 -2
  139. iXBRLViewerPlugin/viewer/src/less/accordian.less +10 -6
  140. iXBRLViewerPlugin/viewer/src/less/block-list.less +16 -5
  141. iXBRLViewerPlugin/viewer/src/less/calculation-inspector.less +83 -0
  142. iXBRLViewerPlugin/viewer/src/less/chart.less +8 -5
  143. iXBRLViewerPlugin/viewer/src/less/colours-dark-mode.less +40 -0
  144. iXBRLViewerPlugin/viewer/src/less/colours.less +32 -20
  145. iXBRLViewerPlugin/viewer/src/less/common.less +3 -3
  146. iXBRLViewerPlugin/viewer/src/less/components.less +6 -4
  147. iXBRLViewerPlugin/viewer/src/less/core.less +2 -0
  148. iXBRLViewerPlugin/viewer/src/less/dialog.less +21 -14
  149. iXBRLViewerPlugin/viewer/src/less/form-controls.less +33 -11
  150. iXBRLViewerPlugin/viewer/src/less/inspector.less +1045 -726
  151. iXBRLViewerPlugin/viewer/src/less/loader.less +2 -2
  152. iXBRLViewerPlugin/viewer/src/less/menu.less +33 -15
  153. iXBRLViewerPlugin/viewer/src/less/summary.less +16 -6
  154. iXBRLViewerPlugin/viewer/src/less/tabs.less +9 -9
  155. iXBRLViewerPlugin/viewer/src/less/text-block-viewer.less +2 -0
  156. iXBRLViewerPlugin/viewer/src/less/text-mixins.less +2 -1
  157. iXBRLViewerPlugin/viewer/src/less/validation-report.less +2 -3
  158. iXBRLViewerPlugin/viewer/src/less/viewer.less +105 -74
  159. iXBRLViewerPlugin/viewer/webpack.common.js +19 -9
  160. iXBRLViewerPlugin/xhtmlserialize.py +59 -45
  161. {ixbrl_viewer-1.4.1.dist-info → ixbrl_viewer-1.4.86.dist-info}/METADATA +181 -50
  162. ixbrl_viewer-1.4.86.dist-info/RECORD +217 -0
  163. {ixbrl_viewer-1.4.1.dist-info → ixbrl_viewer-1.4.86.dist-info}/WHEEL +1 -1
  164. ixbrl_viewer-1.4.1.dist-info/LICENSE → ixbrl_viewer-1.4.86.dist-info/licenses/LICENSE.md +8 -14
  165. {ixbrl_viewer-1.4.1.dist-info → ixbrl_viewer-1.4.86.dist-info}/top_level.txt +0 -1
  166. iXBRLViewerPlugin/viewer/src/js/calculations.js +0 -111
  167. iXBRLViewerPlugin/viewer/src/js/interact.min.js +0 -6
  168. ixbrl_viewer-1.4.1.dist-info/RECORD +0 -155
  169. tests/__init__.py +0 -0
  170. tests/puppeteer/framework/core_elements.js +0 -117
  171. tests/puppeteer/framework/page_objects/doc_frame.js +0 -105
  172. tests/puppeteer/framework/page_objects/fact_details_panel.js +0 -80
  173. tests/puppeteer/framework/page_objects/search_panel.js +0 -76
  174. tests/puppeteer/framework/page_objects/toolbar.js +0 -18
  175. tests/puppeteer/framework/utils.js +0 -3
  176. tests/puppeteer/framework/viewer_page.js +0 -103
  177. tests/puppeteer/puppeteer_test_run_via_intellij.jpg +0 -0
  178. tests/puppeteer/test_filings/filing_documents_smoke_test.zip +0 -0
  179. tests/puppeteer/test_filings/highlights.zip +0 -0
  180. tests/puppeteer/tests/fact_properties.test.js +0 -78
  181. tests/puppeteer/tests/highlight.test.js +0 -186
  182. tests/puppeteer/tests/search.test.js +0 -86
  183. tests/puppeteer/tools/generate.sh +0 -15
  184. tests/unit_tests/__init__.py +0 -0
  185. tests/unit_tests/iXBRLViewerPlugin/__init__.py +0 -0
  186. tests/unit_tests/iXBRLViewerPlugin/mock_arelle.py +0 -39
  187. tests/unit_tests/iXBRLViewerPlugin/test_iXBRLViewer.py +0 -641
  188. tests/unit_tests/iXBRLViewerPlugin/test_xhtmlserialize.py +0 -310
  189. {ixbrl_viewer-1.4.1.dist-info → ixbrl_viewer-1.4.86.dist-info}/entry_points.txt +0 -0
  190. {ixbrl_viewer-1.4.1.dist-info → ixbrl_viewer-1.4.86.dist-info/licenses}/NOTICE +0 -0
@@ -1,6 +1,7 @@
1
1
  # See COPYRIGHT.md for copyright information
2
2
 
3
3
  from __future__ import annotations
4
+
4
5
  import io
5
6
  import json
6
7
  import logging
@@ -12,21 +13,32 @@ import urllib.parse
12
13
  import zipfile
13
14
  from collections import defaultdict
14
15
  from copy import deepcopy
15
- from typing import Optional, Union
16
-
17
- import pycountry
18
- from arelle import ModelXbrl, XbrlConst
19
- from arelle.ModelDocument import Type
16
+ from pathlib import Path
17
+ from typing import Any, Literal, cast
18
+
19
+ from arelle import XbrlConst
20
+ from arelle.Cntlr import Cntlr
21
+ from arelle.ModelDocument import ModelDocument, Type
22
+ from arelle.ModelDtsObject import ModelConcept
23
+ from arelle.ModelInstanceObject import ModelInlineFact, ModelUnit
20
24
  from arelle.ModelRelationshipSet import ModelRelationshipSet
21
- from arelle.ModelValue import QName, INVALIDixVALUE
25
+ from arelle.ModelValue import INVALIDixVALUE, QName
26
+ from arelle.ModelXbrl import ModelXbrl
22
27
  from arelle.UrlUtil import isHttpUrl
23
28
  from arelle.ValidateXbrlCalcs import inferredDecimals
24
29
  from lxml import etree
25
30
 
26
- from .constants import DEFAULT_OUTPUT_NAME, DEFAULT_VIEWER_PATH, FEATURE_CONFIGS
31
+ from .constants import (
32
+ DEFAULT_JS_FILENAME,
33
+ DEFAULT_OUTPUT_NAME,
34
+ ERROR_MESSAGE_CODE,
35
+ FEATURE_CONFIGS,
36
+ INFO_MESSAGE_CODE,
37
+ MANDATORY_FACTS,
38
+ )
27
39
  from .xhtmlserialize import XHTMLSerializer
28
40
 
29
-
41
+ REPORT_TYPE_EXTENSIONS = ('.xbrl', '.xhtml', '.html', '.htm', '.json')
30
42
  UNRECOGNIZED_LINKBASE_LOCAL_DOCUMENTS_TYPE = 'unrecognizedLinkbase'
31
43
  LINK_QNAME_TO_LOCAL_DOCUMENTS_LINKBASE_TYPE = {
32
44
  XbrlConst.qnLinkCalculationLink: 'calcLinkbase',
@@ -45,11 +57,11 @@ class NamespaceMap:
45
57
  required.
46
58
  """
47
59
 
48
- def __init__(self):
49
- self.nsmap = dict()
50
- self.prefixmap = dict()
60
+ def __init__(self) -> None:
61
+ self.nsmap: dict[str, str] = {}
62
+ self.prefixmap: dict[str, str] = {}
51
63
 
52
- def getPrefix(self, ns, preferredPrefix = None):
64
+ def getPrefix(self, ns: str, preferredPrefix: str | None = None) -> str:
53
65
  """
54
66
  Get the prefix for the specified namespace.
55
67
 
@@ -65,58 +77,85 @@ class NamespaceMap:
65
77
  else:
66
78
  p = preferredPrefix if preferredPrefix else "ns"
67
79
  n = 0
68
- while "%s%d" % (p,n) in self.prefixmap:
80
+ while f"{p}{n}" in self.prefixmap:
69
81
  n += 1
70
82
 
71
- prefix = "%s%d" % (p,n)
83
+ prefix = f"{p}{n}"
72
84
 
73
85
  self.prefixmap[prefix] = ns
74
86
  self.nsmap[ns] = prefix
75
87
  return prefix
76
88
 
77
- def qname(self, qname):
78
- return "%s:%s" % (self.getPrefix(qname.namespaceURI, qname.prefix), qname.localName)
89
+ def qname(self, qname: QName) -> str:
90
+ if qname.namespaceURI is None:
91
+ return qname.localName
92
+ return f"{self.getPrefix(qname.namespaceURI, qname.prefix)}:{qname.localName}"
79
93
 
80
94
  class IXBRLViewerBuilderError(Exception):
81
95
  pass
82
96
 
97
+ def isInlineDoc(doc: ModelDocument | None) -> bool:
98
+ return doc is not None and doc.type in {Type.INLINEXBRL, Type.INLINEXBRLDOCUMENTSET}
99
+
83
100
  class IXBRLViewerBuilder:
84
101
 
85
- def __init__(self, dts: ModelXbrl, basenameSuffix: str = ''):
102
+ def __init__(
103
+ self,
104
+ cntlr: Cntlr,
105
+ basenameSuffix: str = "",
106
+ useStubViewer: bool = False,
107
+ features: dict[str, Any] | None = None,
108
+ ):
109
+ if features is None:
110
+ features = {}
111
+ featureNames = {c.key for c in FEATURE_CONFIGS}
112
+ for featureName in features:
113
+ assert featureName in featureNames, \
114
+ f'Given feature name `{featureName}` does not match any defined features: {featureNames}'
115
+ self.reportZip: str | None = None
86
116
  self.nsmap = NamespaceMap()
87
117
  self.roleMap = NamespaceMap()
88
- self.dts = dts
89
- self.taxonomyData = {
90
- "concepts": {},
91
- "languages": {},
92
- "facts": {},
93
- "features": [],
118
+ self.taxonomyData: dict[str, Any] = {
119
+ "sourceReports": [],
120
+ "features": features,
94
121
  }
95
- self.footnoteRelationshipSet = ModelRelationshipSet(dts, "XBRL-footnotes")
96
122
  self.basenameSuffix = basenameSuffix
123
+ self.currentTargetReport: dict[str, Any] | None = None
124
+ self.useStubViewer = useStubViewer
125
+ self.cntlr = cntlr
97
126
 
98
- def enableFeature(self, featureName: str):
99
- if featureName in self.taxonomyData["features"]:
100
- return
101
- featureNames = [c.key for c in FEATURE_CONFIGS]
102
- assert featureName in featureNames, \
103
- f'Given feature name `{featureName}` does not match any defined features: {featureNames}'
104
- self.taxonomyData["features"].append(featureName)
127
+ self.idGen = 0
128
+ self.roleMap.getPrefix(XbrlConst.standardLabel, "std")
129
+ self.roleMap.getPrefix(XbrlConst.documentationLabel, "doc")
130
+ self.roleMap.getPrefix(XbrlConst.summationItem, "calc")
131
+ self.roleMap.getPrefix(XbrlConst.summationItem11, "calc11")
132
+ self.roleMap.getPrefix(XbrlConst.parentChild, "pres")
133
+ self.roleMap.getPrefix(XbrlConst.dimensionDefault, "d-d")
134
+ self.roleMap.getPrefix(WIDER_NARROWER_ARCROLE, "w-n")
135
+
136
+ self.sourceReportsByFiles: dict[frozenset[str], dict[str, Any]] = {}
137
+ self.iv = iXBRLViewer(cntlr)
138
+ if self.useStubViewer:
139
+ self.iv.addFile(iXBRLViewerFile(DEFAULT_OUTPUT_NAME, self.getStubDocument()))
105
140
 
106
- def outputFilename(self, filename):
141
+ self.fromSingleZIP: bool | None = None
142
+ self.reportCount = 0
143
+ self.assets: list[str] = []
144
+
145
+ def outputFilename(self, filename: str) -> str:
107
146
  (base, ext) = os.path.splitext(filename)
108
147
  return base + self.basenameSuffix + ext
109
148
 
110
- def lineWrap(self, s, n = 80):
149
+ def lineWrap(self, s: str, n: int = 80) -> str:
111
150
  return "\n".join([s[i:i+n] for i in range(0, len(s), n)])
112
151
 
113
- def dateFormat(self, d):
152
+ def dateFormat(self, d: str) -> str:
114
153
  """
115
154
  Strip the time component from an ISO date if it's zero
116
155
  """
117
156
  return re.sub("T00:00:00$", "", d)
118
157
 
119
- def escapeJSONForScriptTag(self, s):
158
+ def escapeJSONForScriptTag(self, s: str) -> str:
120
159
  """
121
160
  JSON encodes XML special characters XML and HTML apply difference escaping rules to content
122
161
  within script tags and we need our output to be valid XML, but treated as HTML by browsers.
@@ -130,52 +169,38 @@ class IXBRLViewerBuilder:
130
169
  """
131
170
  return s.replace("<","\\u003C").replace(">","\\u003E").replace("&","\\u0026")
132
171
 
133
- def makeLanguageName(self, langCode):
134
- code = re.sub("-.*","",langCode)
135
- try:
136
- language = pycountry.languages.lookup(code)
137
- match = re.match(r'^[^-]+-(.*)$',langCode)
138
- name = language.name
139
- if match is not None:
140
- name = "%s (%s)" % (name, match.group(1).upper())
141
- except LookupError:
142
- name = langCode
143
-
144
- return name
145
-
146
- def addLanguage(self, langCode):
147
- if langCode not in self.taxonomyData["languages"]:
148
- self.taxonomyData["languages"][langCode] = self.makeLanguageName(langCode)
149
-
150
- def addELR(self, elr):
172
+ def addRoleDefinition(self, report: ModelXbrl, elr: str) -> None:
151
173
  prefix = self.roleMap.getPrefix(elr)
152
- if self.taxonomyData.setdefault("roleDefs",{}).get(prefix, None) is None:
153
- rts = self.dts.roleTypes.get(elr, [])
174
+ assert self.currentTargetReport is not None, "Current target report must be set to add role definition"
175
+ if self.currentTargetReport.setdefault("roleDefs",{}).get(prefix, None) is None:
176
+ rts = report.roleTypes.get(elr, [])
154
177
  label = next((rt.definition for rt in rts if rt.definition is not None), None)
155
178
  if label is not None:
156
- self.taxonomyData["roleDefs"].setdefault(prefix,{})["en"] = label
179
+ self.currentTargetReport["roleDefs"].setdefault(prefix,{})["en"] = label
157
180
 
158
- def addConcept(self, concept, dimensionType = None):
181
+ def addConcept(self, report: ModelXbrl, concept: ModelConcept | None, dimensionType: str | None = None) -> None:
159
182
  if concept is None:
160
183
  return
161
- labelsRelationshipSet = self.dts.relationshipSet(XbrlConst.conceptLabel)
184
+ labelsRelationshipSet = report.relationshipSet(XbrlConst.conceptLabel)
162
185
  labels = labelsRelationshipSet.fromModelObject(concept)
163
186
  conceptName = self.nsmap.qname(concept.qname)
164
- if conceptName not in self.taxonomyData["concepts"]:
165
- conceptData = {
187
+ assert self.currentTargetReport is not None, "Current target report must be set to add concept"
188
+ if conceptName not in self.currentTargetReport["concepts"]:
189
+ conceptData: dict[str, Any] = {
166
190
  "labels": { }
167
191
  }
168
192
  for lr in labels:
169
193
  l = lr.toModelObject
170
194
  conceptData["labels"].setdefault(self.roleMap.getPrefix(l.role),{})[l.xmlLang.lower()] = l.text;
171
- self.addLanguage(l.xmlLang.lower());
195
+ self.addRoleDefinition(report, l.role)
172
196
 
173
197
  refData = []
174
- for _refRel in concept.modelXbrl.relationshipSet(XbrlConst.conceptReference).fromModelObject(concept):
175
- ref = []
176
- for _refPart in _refRel.toModelObject.iterchildren():
177
- ref.append([_refPart.localName, _refPart.stringValue.strip()])
178
- refData.append(ref)
198
+ if concept.modelXbrl is not None:
199
+ for _refRel in concept.modelXbrl.relationshipSet(XbrlConst.conceptReference).fromModelObject(concept):
200
+ ref = []
201
+ for _refPart in _refRel.toModelObject.iterchildren():
202
+ ref.append([_refPart.localName, _refPart.stringValue.strip()])
203
+ refData.append(ref)
179
204
 
180
205
  if len(refData) > 0:
181
206
  conceptData['r'] = refData
@@ -186,32 +211,45 @@ class IXBRLViewerBuilder:
186
211
  if concept.isEnumeration:
187
212
  conceptData["e"] = True
188
213
 
189
- if concept.type is not None and concept.type.isTextBlock:
214
+ if concept.isTextBlock:
190
215
  conceptData['t'] = True
191
216
 
217
+ if concept.balance is not None:
218
+ conceptData['b'] = concept.balance
219
+
220
+ if concept.type is not None:
221
+ conceptData['dt'] = self.nsmap.qname(concept.type.qname)
222
+
192
223
  if concept.isTypedDimension:
193
224
  typedDomainElement = concept.typedDomainElement
194
225
  if typedDomainElement is not None:
195
226
  typedDomainName = self.nsmap.qname(typedDomainElement.qname)
196
227
  conceptData['td'] = typedDomainName
197
- self.addConcept(typedDomainElement)
228
+ self.addConcept(report, typedDomainElement)
198
229
 
199
- self.taxonomyData["concepts"][conceptName] = conceptData
230
+ self.currentTargetReport["concepts"][conceptName] = conceptData
200
231
 
201
- def treeWalk(self, rels, item, indent = 0):
232
+ def treeWalk(self, rels: ModelRelationshipSet, item: Any, indent: int = 0) -> None:
202
233
  for r in rels.fromModelObject(item):
203
234
  if r.toModelObject is not None:
204
235
  self.treeWalk(rels, r.toModelObject, indent + 1)
205
236
 
206
- def getRelationships(self):
207
- rels = {}
237
+ def getRelationships(self, report: ModelXbrl) -> dict[str, dict[str, dict[str, list[dict[str, str]]]]]:
238
+ rels: dict[str, dict[str, dict[str, list[dict[str, str]]]]] = {}
208
239
 
209
- for baseSetKey, baseSetModelLinks in self.dts.baseSets.items():
210
- arcrole, ELR, linkqname, arcqname = baseSetKey
211
- if arcrole in (XbrlConst.summationItem, WIDER_NARROWER_ARCROLE, XbrlConst.parentChild, XbrlConst.dimensionDefault) and ELR is not None:
212
- self.addELR(ELR)
213
- rr = dict()
214
- relSet = self.dts.relationshipSet(arcrole, ELR)
240
+ arcroles = {
241
+ XbrlConst.summationItem,
242
+ XbrlConst.summationItem11,
243
+ WIDER_NARROWER_ARCROLE,
244
+ XbrlConst.parentChild,
245
+ XbrlConst.dimensionDefault,
246
+ }
247
+ for baseSetKey in report.baseSets:
248
+ arcrole, ELR, _linkqname, _arcqname = baseSetKey
249
+ if ELR is not None and arcrole in arcroles:
250
+ self.addRoleDefinition(report, ELR)
251
+ rr: dict[str, list[dict[str, str]]] = {}
252
+ relSet = report.relationshipSet(arcrole, ELR)
215
253
  for r in relSet.modelRelationships:
216
254
  if r.fromModelObject is not None and r.toModelObject is not None:
217
255
  fromKey = self.nsmap.qname(r.fromModelObject.qname)
@@ -221,21 +259,19 @@ class IXBRLViewerBuilder:
221
259
  if r.weight is not None:
222
260
  rel['w'] = r.weight
223
261
  rr.setdefault(fromKey, []).append(rel)
224
- self.addConcept(r.toModelObject)
225
- self.addConcept(r.fromModelObject)
262
+ self.addConcept(report, r.toModelObject)
263
+ self.addConcept(report, r.fromModelObject)
226
264
 
227
- rels.setdefault(self.roleMap.getPrefix(arcrole),{})[self.roleMap.getPrefix(ELR)] = rr
265
+ rels.setdefault(self.roleMap.getPrefix(arcrole), {})[self.roleMap.getPrefix(ELR)] = rr
228
266
  return rels
229
267
 
230
- def validationErrors(self):
231
- dts = self.dts
232
-
233
- logHandler = dts.modelManager.cntlr.logHandler
234
- if getattr(logHandler, "logRecordBuffer") is None:
268
+ def validationErrors(self) -> list[dict[str, str]]:
269
+ logHandler = self.cntlr.logHandler
270
+ if getattr(logHandler, "logRecordBuffer", None) is None:
235
271
  raise IXBRLViewerBuilderError("Logging is not configured to use a buffer. Unable to retrieve validation messages")
236
272
 
237
- errors = []
238
- for logRec in getattr(logHandler, "logRecordBuffer"):
273
+ errors: list[dict[str, str]] = []
274
+ for logRec in getattr(logHandler, "logRecordBuffer", []):
239
275
  if logRec.levelno > logging.INFO:
240
276
  errors.append({
241
277
  "sev": logRec.levelname.title().upper(),
@@ -245,20 +281,23 @@ class IXBRLViewerBuilder:
245
281
 
246
282
  return errors
247
283
 
248
- def addFact(self, f):
284
+ def addFact(self, report: ModelXbrl, f: ModelInlineFact) -> None:
249
285
  if f.id is None:
250
- f.set("id","ixv-%d" % (self.idGen))
286
+ f.set("id", f"ixv-{self.idGen}")
251
287
 
252
288
  self.idGen += 1
253
289
  conceptName = self.nsmap.qname(f.qname)
290
+ factList = MANDATORY_FACTS.get(self.taxonomyData["features"].get("mandatory_facts"), [])
291
+ isMandatory = f.qname.localName in factList
254
292
  scheme, ident = f.context.entityIdentifier
255
293
 
256
294
  aspects = {
257
295
  "c": conceptName,
258
296
  "e": self.nsmap.qname(QName(self.nsmap.getPrefix(scheme,"e"), scheme, ident)),
297
+ "m": isMandatory
259
298
  }
260
299
 
261
- factData = {
300
+ factData: dict[str, Any] = {
262
301
  "a": aspects,
263
302
  }
264
303
 
@@ -266,11 +305,15 @@ class IXBRLViewerBuilder:
266
305
  factData["v"] = None
267
306
  elif f.concept is not None and f.concept.isEnumeration:
268
307
  qnEnums = f.xValue
269
- if not isinstance(qnEnums, list):
270
- qnEnums = (qnEnums,)
271
- factData["v"] = " ".join(self.nsmap.qname(qn) for qn in qnEnums)
272
- for qn in qnEnums:
273
- self.addConcept(self.dts.qnameConcepts.get(qn))
308
+ if qnEnums is None:
309
+ factData["v"] = f.value
310
+ factData["err"] = 'INVALID_IX_VALUE'
311
+ else:
312
+ if not isinstance(qnEnums, list):
313
+ qnEnums = (qnEnums,)
314
+ factData["v"] = " ".join(self.nsmap.qname(qn) for qn in qnEnums)
315
+ for qn in qnEnums:
316
+ self.addConcept(report, report.qnameConcepts.get(qn))
274
317
  else:
275
318
  factData["v"] = f.value
276
319
  if f.value == INVALIDixVALUE:
@@ -292,24 +335,21 @@ class IXBRLViewerBuilder:
292
335
  if d != float("INF") and not math.isnan(d):
293
336
  factData["d"] = d
294
337
 
295
- for d, v in f.context.qnameDims.items():
338
+ for v in f.context.qnameDims.values():
296
339
  if v.memberQname is not None:
297
340
  aspects[self.nsmap.qname(v.dimensionQname)] = self.nsmap.qname(v.memberQname)
298
- self.addConcept(v.member)
299
- self.addConcept(v.dimension, dimensionType = "e")
341
+ self.addConcept(report, v.member)
342
+ self.addConcept(report, v.dimension, dimensionType = "e")
300
343
  elif v.typedMember is not None:
301
344
  aspects[self.nsmap.qname(v.dimensionQname)] = v.typedMember.text
302
- self.addConcept(v.dimension, dimensionType = "t")
345
+ self.addConcept(report, v.dimension, dimensionType = "t")
303
346
 
304
347
  if f.context.isForeverPeriod:
305
348
  aspects["p"] = "f"
306
349
  elif f.context.isInstantPeriod and f.context.instantDatetime is not None:
307
350
  aspects["p"] = self.dateFormat(f.context.instantDatetime.isoformat())
308
351
  elif f.context.isStartEndPeriod and f.context.startDatetime is not None and f.context.endDatetime is not None:
309
- aspects["p"] = "%s/%s" % (
310
- self.dateFormat(f.context.startDatetime.isoformat()),
311
- self.dateFormat(f.context.endDatetime.isoformat())
312
- )
352
+ aspects["p"] = f"{self.dateFormat(f.context.startDatetime.isoformat())}/{self.dateFormat(f.context.endDatetime.isoformat())}"
313
353
 
314
354
  frels = self.footnoteRelationshipSet.fromModelObject(f)
315
355
  if frels:
@@ -317,10 +357,11 @@ class IXBRLViewerBuilder:
317
357
  if frel.toModelObject is not None:
318
358
  factData.setdefault("fn", []).append(frel.toModelObject.id)
319
359
 
320
- self.taxonomyData["facts"][f.id] = factData
321
- self.addConcept(f.concept)
360
+ assert self.currentTargetReport is not None, "Current target report must be set to add fact"
361
+ self.currentTargetReport["facts"][f.id] = factData
362
+ self.addConcept(report, f.concept)
322
363
 
323
- def oimUnitString(self, unit):
364
+ def oimUnitString(self, unit: ModelUnit) -> str:
324
365
  """
325
366
  Returns an OIM-format string representation of the given ModelUnit.
326
367
  See https://www.xbrl.org/Specification/oim-common/REC-2021-10-13/oim-common-REC-2021-10-13.html#term-unit-string-representation
@@ -333,22 +374,22 @@ class IXBRLViewerBuilder:
333
374
  denominatorsString = '*'.join(self.nsmap.qname(x) for x in sorted(denominators))
334
375
  if len(denominators) > 1:
335
376
  if len(numerators) > 1:
336
- return "({})/({})".format(numeratorsString, denominatorsString)
337
- return "{}/({})".format(numeratorsString, denominatorsString)
377
+ return f"({numeratorsString})/({denominatorsString})"
378
+ return f"{numeratorsString}/({denominatorsString})"
338
379
  else:
339
380
  if len(numerators) > 1:
340
- return "({})/{}".format(numeratorsString, denominatorsString)
341
- return "{}/{}".format(numeratorsString, denominatorsString)
381
+ return f"({numeratorsString})/{denominatorsString}"
382
+ return f"{numeratorsString}/{denominatorsString}"
342
383
  return numeratorsString
343
384
 
344
- def addViewerToXMLDocument(self, xmlDocument, scriptUrl):
385
+ def addViewerData(self, viewerFile: 'iXBRLViewerFile', scriptUrl: str) -> bool:
345
386
  taxonomyDataJSON = self.escapeJSONForScriptTag(json.dumps(self.taxonomyData, indent=1, allow_nan=False))
346
387
 
347
- for child in xmlDocument.getroot():
388
+ for child in viewerFile.xmlDocument.getroot():
348
389
  if child.tag == '{http://www.w3.org/1999/xhtml}body':
349
390
  for body_child in child:
350
- if body_child.tag == '{http://www.w3.org/1999/xhtml}script' and body_child.get('type','') == 'application/x.ixbrl-viewer+json':
351
- self.dts.error("viewer:error", "File already contains iXBRL viewer")
391
+ if body_child.tag == '{http://www.w3.org/1999/xhtml}script' and body_child.get('type', '') == 'application/x.ixbrl-viewer+json':
392
+ self.cntlr.addToLog("File already contains iXBRL viewer", messageCode="error")
352
393
  return False
353
394
 
354
395
  child.append(etree.Comment("BEGIN IXBRL VIEWER EXTENSIONS"))
@@ -372,82 +413,85 @@ class IXBRLViewerBuilder:
372
413
  return True
373
414
  return False
374
415
 
375
- def getStubDocument(self):
416
+ def getStubDocument(self) -> etree._ElementTree[etree._Element]:
376
417
  with open(os.path.join(os.path.dirname(__file__),"stubviewer.html")) as fin:
377
418
  return etree.parse(fin)
378
419
 
379
- def createViewer(self, scriptUrl: str = DEFAULT_VIEWER_PATH, useStubViewer: bool = False, showValidations: bool = True, packageDownloadURL: str = None) -> Optional[iXBRLViewer]:
380
- """
381
- Create an iXBRL file with XBRL data as a JSON blob, and script tags added.
382
- :param scriptUrl: The `src` value of the script tag that loads the viewer script.
383
- :param useStubViewer: True if stub document should be included in output.
384
- :param showValidations: True if validation errors should be included in output taxonomy data.
385
- :return: An iXBRLViewer instance that is ready to be saved.
386
- """
387
- dts = self.dts
388
- iv = iXBRLViewer(dts)
389
- self.idGen = 0
390
- self.roleMap.getPrefix(XbrlConst.standardLabel, "std")
391
- self.roleMap.getPrefix(XbrlConst.documentationLabel, "doc")
392
- self.roleMap.getPrefix(XbrlConst.summationItem, "calc")
393
- self.roleMap.getPrefix(XbrlConst.parentChild, "pres")
394
- self.roleMap.getPrefix(XbrlConst.dimensionDefault, "d-d")
395
- self.roleMap.getPrefix(WIDER_NARROWER_ARCROLE, "w-n")
396
-
397
- docSetFiles = None
398
-
399
- for f in dts.facts:
400
- self.addFact(f)
401
-
402
- self.taxonomyData["prefixes"] = self.nsmap.prefixmap
403
- self.taxonomyData["roles"] = self.roleMap.prefixmap
404
- self.taxonomyData["rels"] = self.getRelationships()
420
+ def newTargetReport(self, target: str | None) -> dict[str, Any]:
421
+ return {
422
+ "concepts": {},
423
+ "facts": {},
424
+ "target": target,
425
+ }
405
426
 
406
- if showValidations:
407
- self.taxonomyData["validation"] = self.validationErrors()
427
+ def addSourceReport(self) -> dict[str, list[Any]]:
428
+ sourceReport: dict[str, list[Any]] = {
429
+ "targetReports": []
430
+ }
431
+ self.taxonomyData["sourceReports"].append(sourceReport)
432
+ return sourceReport
433
+
434
+ def processModel(self, report: ModelXbrl) -> None:
435
+ self.footnoteRelationshipSet = ModelRelationshipSet(report, "XBRL-footnotes") # type: ignore[no-untyped-call]
436
+ self.currentTargetReport = self.newTargetReport(getattr(report, "ixdsTarget", None))
437
+ softwareCredits = set()
438
+ for document in report.urlDocs.values():
439
+ if isInlineDoc(document):
440
+ matches = document.creationSoftwareMatches(document.creationSoftwareComment)
441
+ softwareCredits.update(matches)
442
+ if softwareCredits:
443
+ self.currentTargetReport["softwareCredits"] = list(softwareCredits)
444
+ for f in report.facts:
445
+ if f.isTuple:
446
+ for nestedTupleFact in f.ixIter(): # type: ignore[attr-defined]
447
+ self.addFact(report, nestedTupleFact)
448
+ else:
449
+ self.addFact(report, cast(ModelInlineFact, f))
450
+ self.currentTargetReport["rels"] = self.getRelationships(report)
408
451
 
409
- dts.info("viewer:info", "Creating iXBRL viewer")
410
- if dts.modelDocument.type == Type.INLINEXBRLDOCUMENTSET:
452
+ docSetFiles = None
453
+ self.reportCount += 1
454
+ report.info(
455
+ INFO_MESSAGE_CODE,
456
+ f"Creating iXBRL viewer ({self.reportCount}) [{self.currentTargetReport['target']}]",
457
+ )
458
+ assert report.modelDocument is not None
459
+ if report.modelDocument.type == Type.INLINEXBRLDOCUMENTSET:
411
460
  # Sort by object index to preserve order in which files were specified.
412
461
  xmlDocsByFilename = {
413
- os.path.basename(self.outputFilename(doc.filepath)): deepcopy(doc.xmlDocument)
414
- for doc in sorted(dts.modelDocument.referencesDocument.keys(), key=lambda x: x.objectIndex)
462
+ os.path.basename(self.outputFilename(doc.filepath)): doc.xmlDocument
463
+ for doc in sorted(report.modelDocument.referencesDocument.keys(), key=lambda x: x.objectIndex)
464
+ if doc.type == Type.INLINEXBRL
415
465
  }
416
466
  docSetFiles = list(xmlDocsByFilename.keys())
417
467
 
418
- if useStubViewer:
419
- xmlDocument = self.getStubDocument()
420
- iv.addFile(iXBRLViewerFile(DEFAULT_OUTPUT_NAME, xmlDocument))
421
- else:
422
- xmlDocument = next(iter(xmlDocsByFilename.values()))
423
-
424
468
  for filename, docSetXMLDoc in xmlDocsByFilename.items():
425
- iv.addFile(iXBRLViewerFile(filename, docSetXMLDoc))
469
+ self.iv.addFile(iXBRLViewerFile(filename, docSetXMLDoc))
426
470
 
427
- elif useStubViewer:
428
- xmlDocument = self.getStubDocument()
429
- filename = self.outputFilename(os.path.basename(dts.modelDocument.filepath))
471
+ elif self.useStubViewer:
472
+ filename = self.outputFilename(os.path.basename(report.modelDocument.filepath))
430
473
  docSetFiles = [ filename ]
431
- iv.addFile(iXBRLViewerFile(DEFAULT_OUTPUT_NAME, xmlDocument))
432
- iv.addFile(iXBRLViewerFile(filename, dts.modelDocument.xmlDocument))
474
+ self.iv.addFile(iXBRLViewerFile(filename, report.modelDocument.xmlDocument))
433
475
 
434
476
  else:
435
- xmlDocument = deepcopy(dts.modelDocument.xmlDocument)
436
- iv.addFile(iXBRLViewerFile('xbrlviewer.html', xmlDocument))
437
-
438
- if packageDownloadURL is not None:
439
- self.taxonomyData["filingDocuments"] = packageDownloadURL
440
- elif os.path.dirname(self.dts.modelDocument.filepath).endswith('.zip'):
441
- filingDocZipPath = os.path.dirname(self.dts.modelDocument.filepath)
442
- filingDocZipName = os.path.basename(filingDocZipPath)
443
- iv.addFilingDoc(filingDocZipPath)
444
- self.taxonomyData["filingDocuments"] = filingDocZipName
477
+ srcFilename = self.outputFilename(os.path.basename(report.modelDocument.filepath))
478
+ docSetFiles = [ srcFilename ]
479
+ filename = srcFilename
480
+ self.iv.addFile(iXBRLViewerFile(filename, report.modelDocument.xmlDocument))
481
+ docSetKey = frozenset(docSetFiles)
482
+ sourceReport = self.sourceReportsByFiles.get(docSetKey)
483
+ if sourceReport is None:
484
+ sourceReport = self.addSourceReport()
485
+ self.sourceReportsByFiles[docSetKey] = sourceReport
486
+ sourceReport["docSetFiles"] = list(urllib.parse.quote(f) for f in docSetFiles)
487
+
488
+ sourceReport["targetReports"].append(self.currentTargetReport)
445
489
 
446
490
  localDocs = defaultdict(set)
447
- for path, doc in dts.urlDocs.items():
448
- if isHttpUrl(path):
491
+ for path, doc in report.urlDocs.items():
492
+ if isHttpUrl(path) or doc.type == Type.INLINEXBRLDOCUMENTSET:
449
493
  continue
450
- if doc.type in (Type.INLINEXBRL, Type.INLINEXBRLDOCUMENTSET):
494
+ if doc.type == Type.INLINEXBRL:
451
495
  localDocs[doc.basename].add('inline')
452
496
  elif doc.type == Type.SCHEMA:
453
497
  localDocs[doc.basename].add('schema')
@@ -460,40 +504,107 @@ class IXBRLViewerBuilder:
460
504
  linkbaseIdentifed = True
461
505
  if not linkbaseIdentifed:
462
506
  localDocs[doc.basename].add(UNRECOGNIZED_LINKBASE_LOCAL_DOCUMENTS_TYPE)
463
- self.taxonomyData["localDocs"] = {
507
+ self.currentTargetReport["localDocs"] = {
464
508
  localDoc: sorted(docTypes)
465
509
  for localDoc, docTypes in localDocs.items()
466
510
  }
467
511
 
468
- if docSetFiles is not None:
469
- self.taxonomyData["docSetFiles"] = list(urllib.parse.quote(f) for f in docSetFiles)
512
+ # If we only process a single ZIP, add a download link to it as the
513
+ # "filing documents" on the viewer menu.
514
+ if self.fromSingleZIP is None:
515
+ self.fromSingleZIP = report.modelDocument.filepath.endswith(".zip")
516
+ if self.fromSingleZIP:
517
+ self.filingDocZipPath = os.path.dirname(report.modelDocument.filepath)
518
+ else:
519
+ self.fromSingleZIP = False
520
+ if report.fileSource.isArchive and isinstance(report.fileSource.fs, zipfile.ZipFile):
521
+ filelist = report.fileSource.fs.filelist
522
+ for file in filelist:
523
+ directory, asset = os.path.split(file.filename)
524
+ if "reports" in directory and asset != '' and not asset.lower().endswith(REPORT_TYPE_EXTENSIONS):
525
+ self.assets.append(file.filename)
526
+ if self.assets:
527
+ self.reportZip = report.fileSource.fs.filename
528
+
529
+ def createViewer(
530
+ self,
531
+ scriptUrl: str = DEFAULT_JS_FILENAME,
532
+ showValidations: bool = True,
533
+ packageDownloadURL: str | None = None,
534
+ ) -> 'iXBRLViewer' | None:
535
+ """
536
+ Create an iXBRL file with XBRL data as a JSON blob, and script tags added.
537
+ :param scriptUrl: The `src` value of the script tag that loads the viewer script.
538
+ :param showValidations: True if validation errors should be included in output taxonomy data.
539
+ :return: An iXBRLViewer instance that is ready to be saved.
540
+ """
541
+
542
+ self.taxonomyData["prefixes"] = self.nsmap.prefixmap
543
+ self.taxonomyData["roles"] = self.roleMap.prefixmap
544
+ if showValidations:
545
+ self.taxonomyData["validation"] = self.validationErrors()
546
+
547
+ if packageDownloadURL is not None:
548
+ self.taxonomyData["filingDocuments"] = packageDownloadURL
549
+ elif self.fromSingleZIP:
550
+ filingDocZipName = os.path.basename(self.filingDocZipPath)
551
+ self.iv.addFilingDoc(self.filingDocZipPath)
552
+ self.taxonomyData["filingDocuments"] = filingDocZipName
470
553
 
471
- if not self.addViewerToXMLDocument(xmlDocument, scriptUrl):
554
+ if not self.addViewerData(self.iv.files[0], scriptUrl):
472
555
  return None
473
556
 
474
- return iv
557
+ if len(self.iv.files) == 1:
558
+ # If there is only a single report, call the output file "xbrlviewer.html"
559
+ # We should probably preserve the source file extension here.
560
+ self.iv.files[0].filename = 'xbrlviewer.html'
561
+ if self.assets:
562
+ self.iv.addReportAssets(self.assets)
563
+ if self.reportZip:
564
+ self.iv.reportZip = self.reportZip
565
+ return self.iv
475
566
 
476
567
 
477
568
  class iXBRLViewerFile:
478
569
 
479
- def __init__(self, filename, xmlDocument):
570
+ def __init__(self, filename: str, xmlDocument: etree._ElementTree[etree._Element]) -> None:
480
571
  self.filename = filename
481
- self.xmlDocument = xmlDocument
572
+ self.xmlDocument: etree._ElementTree[etree._Element] = deepcopy(xmlDocument)
573
+ # deepcopy does not retain the Python proxies, so iterating the node
574
+ # tree during serialization will create new ones. However, the original
575
+ # ModelObjectFactory is still referenced, and that references a
576
+ # ModelXbrl that will potentially be closed by the time we serialize.
577
+ # Serialization only requires standard XML features, so the default
578
+ # lxml.etree classes (and thus lookup) are fine.
579
+ if self.xmlDocument.parser is not None:
580
+ self.xmlDocument.parser.set_element_class_lookup(etree.ElementDefaultClassLookup())
482
581
 
483
582
 
484
583
  class iXBRLViewer:
485
584
 
486
- def __init__(self, dts):
487
- self.files = []
488
- self.filingDocuments = None
489
- self.dts = dts
490
- def addFile(self, ivf):
491
- self.files.append(ivf)
585
+ def __init__(self, cntlr: Cntlr) -> None:
586
+ self.reportZip: str | None = None
587
+ self.filesByFilename: dict[str, iXBRLViewerFile] = {}
588
+ self.filingDocuments: str | None = None
589
+ self.cntlr = cntlr
590
+ self.assets: list[str] = []
492
591
 
493
- def addFilingDoc(self, filingDocuments):
592
+ def addReportAssets(self, assets: list[str]) -> None:
593
+ self.assets.extend(assets)
594
+
595
+ def addFile(self, ivf: iXBRLViewerFile) -> None:
596
+ # Overwrite previous occurrences of the same document, because it may
597
+ # have had more IDs added to it by subsequent target documents.
598
+ self.filesByFilename[ivf.filename] = ivf
599
+
600
+ @property
601
+ def files(self) -> list[iXBRLViewerFile]:
602
+ return list(self.filesByFilename.values())
603
+
604
+ def addFilingDoc(self, filingDocuments: str) -> None:
494
605
  self.filingDocuments = filingDocuments
495
606
 
496
- def save(self, destination: Union[io.BytesIO, str], zipOutput: bool = False, copyScriptPath: Optional[str] = None):
607
+ def save(self, destination: io.BytesIO | str, zipOutput: bool = False, copyScriptPath: Path | None = None) -> None:
497
608
  """
498
609
  Save the iXBRL viewer.
499
610
  :param destination: The target that viewer data/files will be written to (path to file/directory, or a file object itself).
@@ -502,82 +613,113 @@ class iXBRLViewer:
502
613
  """
503
614
  if isinstance(destination, io.BytesIO) or zipOutput: # zip output stream
504
615
  # zipfile may be cumulatively added to by inline extraction, EdgarRenderer etc
616
+ filepath: io.BytesIO | str
617
+ fileMode: Literal['a', 'w']
505
618
  if isinstance(destination, io.BytesIO):
506
- file = destination
619
+ filepath = destination
507
620
  fileMode = 'a'
508
621
  destination = os.sep
509
622
  elif os.path.isdir(destination):
510
- file = os.path.join(destination, f'{os.path.splitext(os.path.basename(self.files[0].filename))[0]}.zip')
623
+ filepath = os.path.join(
624
+ destination,
625
+ f'{os.path.splitext(os.path.basename(self.files[0].filename))[0]}.zip',
626
+ )
511
627
  fileMode = 'w'
512
628
  elif destination.endswith(os.sep):
513
629
  # Looks like a directory, but isn't one
514
- self.dts.error("viewer:error", "Directory %s does not exist" % destination)
630
+ self.cntlr.addToLog(
631
+ f"Directory {destination} does not exist",
632
+ messageCode=ERROR_MESSAGE_CODE,
633
+ )
515
634
  return
516
635
  elif not os.path.isdir(os.path.dirname(os.path.abspath(destination))):
517
636
  # Directory part of filename doesn't exist
518
- self.dts.error("viewer:error", "Directory %s does not exist" % os.path.dirname(os.path.abspath(destination)))
637
+ self.cntlr.addToLog(
638
+ f"Directory {os.path.dirname(os.path.abspath(destination))} does not exist",
639
+ messageCode=ERROR_MESSAGE_CODE,
640
+ )
519
641
  return
520
642
  elif not destination.endswith('.zip'):
521
643
  # File extension isn't a zip
522
- self.dts.error("viewer:error", "File extension %s is not a zip" % os.path.splitext(destination)[0])
644
+ self.cntlr.addToLog(
645
+ f"File extension {os.path.splitext(destination)[0]} is not a zip",
646
+ messageCode=ERROR_MESSAGE_CODE,
647
+ )
523
648
  return
524
649
  else:
525
- file = destination
650
+ filepath = destination
526
651
  fileMode = 'w'
527
652
 
528
- with zipfile.ZipFile(file, fileMode, zipfile.ZIP_DEFLATED, True) as zout:
653
+ with zipfile.ZipFile(filepath, fileMode, zipfile.ZIP_DEFLATED, allowZip64=True) as zout:
529
654
  for f in self.files:
530
- self.dts.info("viewer:info", "Saving in output zip %s" % f.filename)
655
+ self.cntlr.addToLog(f"Saving in output zip {f.filename}", messageCode=INFO_MESSAGE_CODE)
531
656
  with zout.open(f.filename, "w") as fout:
532
657
  writer = XHTMLSerializer(fout)
533
658
  writer.serialize(f.xmlDocument)
534
659
  if self.filingDocuments:
535
660
  filename = os.path.basename(self.filingDocuments)
536
- self.dts.info("viewer:info", "Writing %s" % filename)
661
+ self.cntlr.addToLog(f"Writing {filename}", messageCode=INFO_MESSAGE_CODE)
537
662
  zout.write(self.filingDocuments, filename)
538
663
  if copyScriptPath is not None:
539
- scriptSrc = os.path.join(destination, copyScriptPath)
540
- self.dts.info("viewer:info", "Writing script from %s" % scriptSrc)
541
- zout.write(scriptSrc, os.path.basename(copyScriptPath))
664
+ self.cntlr.addToLog(f"Writing script from {copyScriptPath}", messageCode=INFO_MESSAGE_CODE)
665
+ zout.write(copyScriptPath, copyScriptPath.name)
542
666
  elif os.path.isdir(destination):
543
667
  # If output is a directory, write each file in the doc set to that
544
668
  # directory using its existing filename
545
669
  for f in self.files:
546
670
  filename = os.path.join(destination, f.filename)
547
- self.dts.info("viewer:info", "Writing %s" % filename)
671
+ self.cntlr.addToLog(f"Writing {filename}", messageCode=INFO_MESSAGE_CODE)
548
672
  with open(filename, "wb") as fout:
549
673
  writer = XHTMLSerializer(fout)
550
674
  writer.serialize(f.xmlDocument)
551
675
  if self.filingDocuments:
552
676
  filename = os.path.basename(self.filingDocuments)
553
- self.dts.info("viewer:info", "Writing %s" % filename)
677
+ self.cntlr.addToLog(f"Writing {filename}", messageCode=INFO_MESSAGE_CODE)
554
678
  shutil.copy2(self.filingDocuments, os.path.join(destination, filename))
679
+ if self.assets and self.reportZip is not None:
680
+ with zipfile.ZipFile(self.reportZip) as z:
681
+ for asset in self.assets:
682
+ fileName = os.path.basename(asset)
683
+ path = os.path.join(destination, fileName)
684
+ self.cntlr.addToLog(f"Writing {asset}", messageCode=INFO_MESSAGE_CODE)
685
+ with z.open(asset) as zf, open(path, 'wb') as assetFile:
686
+ shutil.copyfileobj(zf, assetFile)
687
+
555
688
  if copyScriptPath is not None:
556
- self._copyScript(destination, copyScriptPath)
689
+ self._copyScript(Path(destination), copyScriptPath)
557
690
  else:
558
691
  if len(self.files) > 1:
559
- self.dts.error("viewer:error", "More than one file in input, but output is not a directory")
692
+ self.cntlr.addToLog(
693
+ "More than one file in input, but output is not a directory",
694
+ messageCode=ERROR_MESSAGE_CODE,
695
+ )
560
696
  elif destination.endswith(os.sep):
561
697
  # Looks like a directory, but isn't one
562
- self.dts.error("viewer:error", "Directory %s does not exist" % destination)
698
+ self.cntlr.addToLog(
699
+ f"Directory {destination} does not exist",
700
+ messageCode=ERROR_MESSAGE_CODE,
701
+ )
563
702
  elif not os.path.isdir(os.path.dirname(os.path.abspath(destination))):
564
703
  # Directory part of filename doesn't exist
565
- self.dts.error("viewer:error", "Directory %s does not exist" % os.path.dirname(os.path.abspath(destination)))
704
+ self.cntlr.addToLog(
705
+ f"Directory {os.path.dirname(os.path.abspath(destination))} does not exist",
706
+ messageCode=ERROR_MESSAGE_CODE,
707
+ )
566
708
  else:
567
- self.dts.info("viewer:info", "Writing %s" % destination)
709
+ self.cntlr.addToLog(f"Writing {destination}", messageCode=INFO_MESSAGE_CODE)
568
710
  with open(destination, "wb") as fout:
569
711
  writer = XHTMLSerializer(fout)
570
712
  writer.serialize(self.files[0].xmlDocument)
571
713
  if self.filingDocuments:
572
714
  filename = os.path.basename(self.filingDocuments)
573
- self.dts.info("viewer:info", "Writing %s" % filename)
715
+ self.cntlr.addToLog(f"Writing {filename}", messageCode=INFO_MESSAGE_CODE)
574
716
  shutil.copy2(self.filingDocuments, os.path.join(os.path.dirname(destination), filename))
575
717
  if copyScriptPath is not None:
576
- outDirectory = os.path.dirname(os.path.join(os.getcwd(), destination))
718
+ outDirectory = Path(destination).parent
577
719
  self._copyScript(outDirectory, copyScriptPath)
578
720
 
579
- def _copyScript(self, directory: str, scriptPath: str):
580
- scriptSrc = os.path.join(directory, scriptPath)
581
- scriptDest = os.path.join(directory, os.path.basename(scriptPath))
582
- self.dts.info("viewer:info", "Copying script from %s to %s" % (scriptSrc, scriptDest))
583
- shutil.copy2(scriptSrc, scriptDest)
721
+ def _copyScript(self, destDirectory: Path, scriptPath: Path) -> None:
722
+ scriptDest = destDirectory / scriptPath.name
723
+ if scriptPath != scriptDest:
724
+ self.cntlr.addToLog(f"Copying script from {scriptPath} to {scriptDest}.", messageCode=INFO_MESSAGE_CODE)
725
+ shutil.copy2(scriptPath, scriptDest)