@fin.cx/opendata 4.7.0 → 4.8.0
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.
- package/.smartconfig.json +6 -2
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/financialstatements/amounts.d.ts +58 -0
- package/dist_ts/financialstatements/amounts.js +130 -0
- package/dist_ts/financialstatements/classes.financialstatementerror.d.ts +24 -0
- package/dist_ts/financialstatements/classes.financialstatementerror.js +10 -0
- package/dist_ts/financialstatements/constants.d.ts +16 -0
- package/dist_ts/financialstatements/constants.js +20 -0
- package/dist_ts/financialstatements/dates.d.ts +16 -0
- package/dist_ts/financialstatements/dates.js +72 -0
- package/dist_ts/financialstatements/html.d.ts +21 -0
- package/dist_ts/financialstatements/html.js +92 -0
- package/dist_ts/financialstatements/index.d.ts +6 -0
- package/dist_ts/financialstatements/index.js +11 -0
- package/dist_ts/financialstatements/interfaces.d.ts +258 -0
- package/dist_ts/financialstatements/interfaces.js +2 -0
- package/dist_ts/financialstatements/layout.d.ts +20 -0
- package/dist_ts/financialstatements/layout.html.d.ts +8 -0
- package/dist_ts/financialstatements/layout.html.js +125 -0
- package/dist_ts/financialstatements/layout.js +2 -0
- package/dist_ts/financialstatements/layout.pdf.d.ts +6 -0
- package/dist_ts/financialstatements/layout.pdf.js +229 -0
- package/dist_ts/financialstatements/mediatypes.d.ts +3 -0
- package/dist_ts/financialstatements/mediatypes.js +11 -0
- package/dist_ts/financialstatements/outline.d.ts +22 -0
- package/dist_ts/financialstatements/outline.js +26 -0
- package/dist_ts/financialstatements/parse.esef.d.ts +13 -0
- package/dist_ts/financialstatements/parse.esef.js +349 -0
- package/dist_ts/financialstatements/parse.hgb.d.ts +9 -0
- package/dist_ts/financialstatements/parse.hgb.js +1014 -0
- package/dist_ts/financialstatements/parse.statement.d.ts +8 -0
- package/dist_ts/financialstatements/parse.statement.js +25 -0
- package/dist_ts/financialstatements/plugins.d.ts +10 -0
- package/dist_ts/financialstatements/plugins.js +15 -0
- package/dist_ts/financialstatements/read.d.ts +10 -0
- package/dist_ts/financialstatements/read.js +113 -0
- package/package.json +14 -4
- package/readme.md +82 -4
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/financialstatements/amounts.ts +181 -0
- package/ts/financialstatements/classes.financialstatementerror.ts +35 -0
- package/ts/financialstatements/constants.ts +24 -0
- package/ts/financialstatements/dates.ts +89 -0
- package/ts/financialstatements/html.ts +107 -0
- package/ts/financialstatements/index.ts +17 -0
- package/ts/financialstatements/interfaces.ts +268 -0
- package/ts/financialstatements/layout.html.ts +130 -0
- package/ts/financialstatements/layout.pdf.ts +268 -0
- package/ts/financialstatements/layout.ts +16 -0
- package/ts/financialstatements/mediatypes.ts +14 -0
- package/ts/financialstatements/outline.ts +44 -0
- package/ts/financialstatements/parse.esef.ts +394 -0
- package/ts/financialstatements/parse.hgb.ts +1235 -0
- package/ts/financialstatements/parse.statement.ts +26 -0
- package/ts/financialstatements/plugins.ts +29 -0
- package/ts/financialstatements/read.ts +135 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The largest document read, and the largest report a package may hold: 100 MiB. */
|
|
2
|
+
export const maxStatementBytes = 100 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
/** The most pages a PDF may have. */
|
|
5
|
+
export const maxPdfPages = 500;
|
|
6
|
+
|
|
7
|
+
/** The most numeric facts an ESEF report may tag. */
|
|
8
|
+
export const maxEsefFacts = 100_000;
|
|
9
|
+
|
|
10
|
+
/** The most entries an ESEF package may hold. */
|
|
11
|
+
export const maxEsefPackageEntries = 10_000;
|
|
12
|
+
|
|
13
|
+
/** The origin note the Unternehmensregister marks what it gives out with (Nutzungsbedingungen § 3 Abs. 5). */
|
|
14
|
+
export const unternehmensregisterOrigin = 'Auszug aus dem Unternehmensregister';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The full origin note of a document from the Unternehmensregister: "Auszug aus dem
|
|
18
|
+
* Unternehmensregister" and the day the document was posted, or for a deposited balance sheet the day
|
|
19
|
+
* it was deposited (§ 326 Abs. 2 HGB).
|
|
20
|
+
*/
|
|
21
|
+
export const unternehmensregisterNotice = (publishedOnArg: string, depositedArg = false): string => {
|
|
22
|
+
const [year, month, day] = publishedOnArg.split('-');
|
|
23
|
+
return `${unternehmensregisterOrigin}, ${depositedArg ? 'hinterlegt' : 'eingestellt'} am ${day}.${month}.${year}`;
|
|
24
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { TIsoDate } from './interfaces.js';
|
|
2
|
+
|
|
3
|
+
const monthNumbers: Record<string, number> = {
|
|
4
|
+
januar: 1, jan: 1, jänner: 1, februar: 2, feb: 2, märz: 3, mär: 3, maerz: 3, april: 4, apr: 4, mai: 5,
|
|
5
|
+
juni: 6, jun: 6, juli: 7, jul: 7, august: 8, aug: 8, september: 9, sep: 9, sept: 9, oktober: 10, okt: 10,
|
|
6
|
+
november: 11, nov: 11, dezember: 12, dez: 12,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** `31.12.2024`, `31. Dezember 2024`, `1.1.`, `01. Jan.`: a day, a month and maybe a year */
|
|
10
|
+
const datePattern = /(\d{1,2})\.\s*(?:(\d{1,2})\.|([A-Za-zÄäÖöÜü]{3,9})\.?(?=[\s\d]|$))\s*((?:19|20)\d{2})?/g;
|
|
11
|
+
|
|
12
|
+
interface IDateMatch {
|
|
13
|
+
day: number;
|
|
14
|
+
month: number;
|
|
15
|
+
year: number | null;
|
|
16
|
+
index: number;
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isoOf = (yearArg: number, monthArg: number, dayArg: number): TIsoDate | null => {
|
|
21
|
+
const date = new Date(Date.UTC(yearArg, monthArg - 1, dayArg));
|
|
22
|
+
if (date.getUTCFullYear() !== yearArg || date.getUTCMonth() !== monthArg - 1 || date.getUTCDate() !== dayArg) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return date.toISOString().slice(0, 10);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const dateMatches = (textArg: string): IDateMatch[] => {
|
|
29
|
+
const result: IDateMatch[] = [];
|
|
30
|
+
for (const match of textArg.matchAll(datePattern)) {
|
|
31
|
+
const month = match[2] !== undefined ? Number(match[2]) : monthNumbers[match[3].toLowerCase()];
|
|
32
|
+
const day = Number(match[1]);
|
|
33
|
+
if (month === undefined || month < 1 || month > 12 || day < 1 || day > 31) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
result.push({
|
|
37
|
+
day,
|
|
38
|
+
month,
|
|
39
|
+
year: match[4] !== undefined ? Number(match[4]) : null,
|
|
40
|
+
index: match.index,
|
|
41
|
+
end: match.index + match[0].length,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** the last full date a text names, `31.12.2024` or `31. Dezember 2024`; null if none */
|
|
48
|
+
export const lastDateIn = (textArg: string): TIsoDate | null => {
|
|
49
|
+
const full = dateMatches(textArg).filter((matchArg) => matchArg.year !== null);
|
|
50
|
+
const last = full[full.length - 1];
|
|
51
|
+
return last ? isoOf(last.year as number, last.month, last.day) : null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** a text with the dates it names blanked out, `31.12.2024`, `1. Januar` and the like */
|
|
55
|
+
export const withoutDates = (textArg: string): string => {
|
|
56
|
+
let text = textArg;
|
|
57
|
+
for (const match of dateMatches(textArg).reverse()) {
|
|
58
|
+
text = text.slice(0, match.index) + ' '.repeat(match.end - match.index) + text.slice(match.end);
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** the last year a text names on its own, outside a date; null if none */
|
|
64
|
+
export const lastYearIn = (textArg: string): number | null => {
|
|
65
|
+
const years = [...withoutDates(textArg).matchAll(/\b((?:19|20)\d{2})\b/g)];
|
|
66
|
+
return years.length > 0 ? Number(years[years.length - 1][1]) : null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The period a line of a statement names: `vom 1. Januar bis 31. Dezember 2024` and
|
|
71
|
+
* `01.01.-31.12.2024` give both days, `zum 31. Dezember 2024` the last. A first day printed without
|
|
72
|
+
* its year is in the last day's year, or the year before where it would fall after the last day.
|
|
73
|
+
*/
|
|
74
|
+
export const periodIn = (textArg: string): { start: TIsoDate | null; end: TIsoDate | null } => {
|
|
75
|
+
const matches = dateMatches(textArg);
|
|
76
|
+
for (let index = 0; index + 1 < matches.length; index++) {
|
|
77
|
+
const first = matches[index];
|
|
78
|
+
const second = matches[index + 1];
|
|
79
|
+
const between = textArg.slice(first.end, second.index);
|
|
80
|
+
if (second.year === null || !/^\s*(?:bis(?:\s+(?:zum|einschließlich))?|[-–—])\s*$/i.test(between)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const end = isoOf(second.year, second.month, second.day);
|
|
84
|
+
const startYear =
|
|
85
|
+
first.year ?? (first.month * 100 + first.day <= second.month * 100 + second.day ? second.year : second.year - 1);
|
|
86
|
+
return { start: end ? isoOf(startYear, first.month, first.day) : null, end };
|
|
87
|
+
}
|
|
88
|
+
return { start: null, end: lastDateIn(textArg) };
|
|
89
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { FinancialStatementError } from './classes.financialstatementerror.js';
|
|
3
|
+
|
|
4
|
+
export type THtmlDocument = plugins.parse5.DefaultTreeAdapterTypes.Document;
|
|
5
|
+
export type THtmlElement = plugins.parse5.DefaultTreeAdapterTypes.Element;
|
|
6
|
+
type THtmlParentNode = plugins.parse5.DefaultTreeAdapterTypes.ParentNode;
|
|
7
|
+
type THtmlChildNode = plugins.parse5.DefaultTreeAdapterTypes.ChildNode;
|
|
8
|
+
|
|
9
|
+
export const parseHtml = (htmlArg: string): THtmlDocument => plugins.parse5.parse(htmlArg);
|
|
10
|
+
|
|
11
|
+
export const isElement = (nodeArg: THtmlChildNode): nodeArg is THtmlElement => 'tagName' in nodeArg;
|
|
12
|
+
|
|
13
|
+
/** the element children of a node */
|
|
14
|
+
export const elementChildren = (nodeArg: THtmlParentNode): THtmlElement[] =>
|
|
15
|
+
nodeArg.childNodes.filter(isElement);
|
|
16
|
+
|
|
17
|
+
/** every element below a node, in document order */
|
|
18
|
+
export const descendants = (nodeArg: THtmlParentNode): THtmlElement[] => {
|
|
19
|
+
const result: THtmlElement[] = [];
|
|
20
|
+
const stack: THtmlElement[] = elementChildren(nodeArg).reverse();
|
|
21
|
+
while (stack.length > 0) {
|
|
22
|
+
const element = stack.pop() as THtmlElement;
|
|
23
|
+
result.push(element);
|
|
24
|
+
const children = element.tagName === 'template' ? [] : elementChildren(element);
|
|
25
|
+
for (let index = children.length - 1; index >= 0; index--) {
|
|
26
|
+
stack.push(children[index]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const getAttribute = (elementArg: THtmlElement, nameArg: string): string | undefined =>
|
|
33
|
+
elementArg.attrs.find((attrArg) => attrArg.name === nameArg)?.value;
|
|
34
|
+
|
|
35
|
+
export const hasClass = (elementArg: THtmlElement, classArg: string): boolean =>
|
|
36
|
+
(getAttribute(elementArg, 'class') ?? '').split(/\s+/).includes(classArg);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* the raw text of a node and everything below it, scripts and styles left out: the text nodes in
|
|
40
|
+
* document order, separated by spaces. Walks the tree with its own stack, so no nesting depth a page
|
|
41
|
+
* can have exhausts the call stack.
|
|
42
|
+
*/
|
|
43
|
+
const rawText = (nodeArg: THtmlChildNode | THtmlParentNode): string => {
|
|
44
|
+
const pieces: string[] = [];
|
|
45
|
+
const stack: Array<THtmlChildNode | THtmlParentNode> = [nodeArg];
|
|
46
|
+
for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
|
|
47
|
+
if ('value' in node && node.nodeName === '#text') {
|
|
48
|
+
pieces.push(node.value);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!('childNodes' in node)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if ('tagName' in node && (node.tagName === 'script' || node.tagName === 'style')) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
for (let index = node.childNodes.length - 1; index >= 0; index--) {
|
|
58
|
+
stack.push(node.childNodes[index]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return pieces.join(' ');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** the text of a node with white space collapsed */
|
|
65
|
+
export const textOf = (nodeArg: THtmlChildNode | THtmlParentNode): string =>
|
|
66
|
+
rawText(nodeArg).replace(/\s+/g, ' ').trim();
|
|
67
|
+
|
|
68
|
+
/** the encodings an HTML statement may declare, by the names it may declare them with */
|
|
69
|
+
const knownEncodings: Record<string, string> = {
|
|
70
|
+
'utf-8': 'utf-8',
|
|
71
|
+
utf8: 'utf-8',
|
|
72
|
+
'iso-8859-1': 'windows-1252',
|
|
73
|
+
'iso-8859-15': 'iso-8859-15',
|
|
74
|
+
latin1: 'windows-1252',
|
|
75
|
+
'windows-1252': 'windows-1252',
|
|
76
|
+
cp1252: 'windows-1252',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decodes an HTML document: by its byte order mark, else by the charset its first 1,024 bytes
|
|
81
|
+
* declare, else as UTF-8. Bytes that are not text in that encoding are `unreadable`.
|
|
82
|
+
*/
|
|
83
|
+
export const decodeHtml = (bytesArg: Uint8Array): string => {
|
|
84
|
+
let encoding = 'utf-8';
|
|
85
|
+
let start = 0;
|
|
86
|
+
if (bytesArg[0] === 0xef && bytesArg[1] === 0xbb && bytesArg[2] === 0xbf) {
|
|
87
|
+
start = 3;
|
|
88
|
+
} else if ((bytesArg[0] === 0xff && bytesArg[1] === 0xfe) || (bytesArg[0] === 0xfe && bytesArg[1] === 0xff)) {
|
|
89
|
+
encoding = bytesArg[0] === 0xff ? 'utf-16le' : 'utf-16be';
|
|
90
|
+
start = 2;
|
|
91
|
+
} else {
|
|
92
|
+
const head = new TextDecoder('windows-1252').decode(bytesArg.subarray(0, 1024));
|
|
93
|
+
const declared = /<meta[^>]+charset\s*=\s*["']?\s*([\w-]+)/i.exec(head)?.[1].toLowerCase();
|
|
94
|
+
if (declared !== undefined) {
|
|
95
|
+
const known = knownEncodings[declared];
|
|
96
|
+
if (known === undefined) {
|
|
97
|
+
throw new FinancialStatementError('unreadable', `The document declares the encoding "${declared}", which is not read here.`);
|
|
98
|
+
}
|
|
99
|
+
encoding = known;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return new TextDecoder(encoding, { fatal: true }).decode(bytesArg.subarray(start));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new FinancialStatementError('unreadable', `The document is not text in ${encoding}.`, { cause: error });
|
|
106
|
+
}
|
|
107
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// This entry point stands alone: it imports nothing from the rest of the package and keeps nothing.
|
|
2
|
+
// It reads published financial statements from the bytes a caller obtained: statements under the
|
|
3
|
+
// HGB from the HTML or the PDF a register shows, and ESEF reports. The caller stores the document
|
|
4
|
+
// and what was read from it; `parsed` is null rather than guessed.
|
|
5
|
+
export * from './interfaces.js';
|
|
6
|
+
export { FinancialStatementError, type TFinancialStatementErrorCode } from './classes.financialstatementerror.js';
|
|
7
|
+
export {
|
|
8
|
+
maxEsefFacts,
|
|
9
|
+
maxEsefPackageEntries,
|
|
10
|
+
maxPdfPages,
|
|
11
|
+
maxStatementBytes,
|
|
12
|
+
unternehmensregisterNotice,
|
|
13
|
+
unternehmensregisterOrigin,
|
|
14
|
+
} from './constants.js';
|
|
15
|
+
export { parseStatementDocument } from './parse.statement.js';
|
|
16
|
+
export { parseEsefPackage, parseEsefReport } from './parse.esef.js';
|
|
17
|
+
export { readFinancialStatement } from './read.js';
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/** An ISO date, `2024-12-31`. */
|
|
2
|
+
export type TIsoDate = string;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a statement was published:
|
|
6
|
+
* - `unternehmensregister`: the Unternehmensregister, for financial years beginning after 31.12.2021,
|
|
7
|
+
* and for the balance sheets micro companies deposit there (§ 326 Abs. 2 HGB);
|
|
8
|
+
* - `bundesanzeiger`: the Bundesanzeiger, for earlier financial years;
|
|
9
|
+
* - `issuer`: the company's own publication, as listed issuers publish their ESEF reports;
|
|
10
|
+
* - `unknown`: a document whose origin the caller does not know.
|
|
11
|
+
*/
|
|
12
|
+
export type TFilingSource = 'unternehmensregister' | 'bundesanzeiger' | 'issuer' | 'unknown';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a filing is:
|
|
16
|
+
* - `annual_accounts`: the Jahresabschluss of one company (§§ 242, 264 HGB);
|
|
17
|
+
* - `consolidated_accounts`: a group's Konzernabschluss (§ 290 HGB);
|
|
18
|
+
* - `deposited_balance_sheet`: the balance sheet a micro company deposits instead of publishing
|
|
19
|
+
* (§ 326 Abs. 2 HGB);
|
|
20
|
+
* - `annual_financial_report`: a listed issuer's Jahresfinanzbericht (§ 114 WpHG), as an ESEF report.
|
|
21
|
+
*/
|
|
22
|
+
export type TFilingKind = 'annual_accounts' | 'consolidated_accounts' | 'deposited_balance_sheet' | 'annual_financial_report';
|
|
23
|
+
|
|
24
|
+
/** The size classes of § 267 and § 267a HGB, as a filing states them. */
|
|
25
|
+
export type TSizeClass = 'micro' | 'small' | 'medium' | 'large';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One published statement as its publisher lists it. Every field the caller does not know is null;
|
|
29
|
+
* none is derived from the document.
|
|
30
|
+
*/
|
|
31
|
+
export interface IFiling {
|
|
32
|
+
source: TFilingSource;
|
|
33
|
+
kind: TFilingKind;
|
|
34
|
+
/** the company's name as the publisher writes it */
|
|
35
|
+
companyName: string | null;
|
|
36
|
+
/** the register sheet, as the caller writes it: `Amtsgericht Bremen HRB 35230 HB` */
|
|
37
|
+
registerReference: string | null;
|
|
38
|
+
/** the company's Legal Entity Identifier */
|
|
39
|
+
lei: string | null;
|
|
40
|
+
fiscalYearStart: TIsoDate | null;
|
|
41
|
+
fiscalYearEnd: TIsoDate | null;
|
|
42
|
+
/**
|
|
43
|
+
* the day the publisher posted the document; for a deposited balance sheet, the day it was
|
|
44
|
+
* deposited. The Unternehmensregister's origin note names this day.
|
|
45
|
+
*/
|
|
46
|
+
publishedOn: TIsoDate | null;
|
|
47
|
+
/** the publisher's own title of the document */
|
|
48
|
+
title: string | null;
|
|
49
|
+
sizeClass: TSizeClass | null;
|
|
50
|
+
/** the publisher's identifier of the document */
|
|
51
|
+
documentId: string | null;
|
|
52
|
+
/** where the publisher shows the document */
|
|
53
|
+
sourceUrl: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a caller states about a filing: `source` and `kind`, and whatever else it knows. */
|
|
57
|
+
export interface IFilingInput extends Pick<IFiling, 'source' | 'kind'>, Partial<Omit<IFiling, 'source' | 'kind'>> {}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The media types this entry point reads:
|
|
61
|
+
* - `text/html`: a statement as the Unternehmensregister or the Bundesanzeiger shows it;
|
|
62
|
+
* - `application/pdf`: the same as a PDF with a text layer;
|
|
63
|
+
* - `application/xhtml+xml`: an ESEF report (inline XBRL) on its own;
|
|
64
|
+
* - `application/zip`: an ESEF report package (`.zip` or `.xbri`).
|
|
65
|
+
*/
|
|
66
|
+
export type TStatementMediaType = 'text/html' | 'application/pdf' | 'application/xhtml+xml' | 'application/zip';
|
|
67
|
+
|
|
68
|
+
/** The document as it was obtained, byte for byte. */
|
|
69
|
+
export interface IRawStatementDocument {
|
|
70
|
+
bytes: Uint8Array;
|
|
71
|
+
mediaType: TStatementMediaType;
|
|
72
|
+
/** the SHA-256 of `bytes`, lowercase hex */
|
|
73
|
+
sha256: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The unit a statement prints its amounts in. Amounts in this entry point are always in the
|
|
78
|
+
* currency itself, the unit's factor applied: `1.234` printed in TEUR is `'1234000'`.
|
|
79
|
+
*/
|
|
80
|
+
export interface IStatementUnit {
|
|
81
|
+
/** as this entry point names it */
|
|
82
|
+
label: 'EUR' | 'TEUR' | 'Mio. EUR';
|
|
83
|
+
/** the number of euros one printed unit stands for */
|
|
84
|
+
factor: '1' | '1000' | '1000000';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One of the two years a statement shows. */
|
|
88
|
+
export interface IStatementPeriod {
|
|
89
|
+
/** the column heading as printed, `Geschäftsjahr Euro` or `31.12.2024`; null where none is printed */
|
|
90
|
+
heading: string | null;
|
|
91
|
+
/** the first day, where the statement states it */
|
|
92
|
+
start: TIsoDate | null;
|
|
93
|
+
/** the balance sheet date, the last day of the period, where the statement states it */
|
|
94
|
+
end: TIsoDate | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The figures most readers look for first, as decimal strings in the currency. Each is null where
|
|
99
|
+
* the statement does not print it:
|
|
100
|
+
* - `totalAssets`: the balance sheet total;
|
|
101
|
+
* - `equity`: the equity section of the balance sheet, its printed total or, where the statement
|
|
102
|
+
* prints only its parts, their sum;
|
|
103
|
+
* - `netIncome`: the net income or loss (Jahresüberschuss or Jahresfehlbetrag), from the income
|
|
104
|
+
* statement or, where none is published, from the balance sheet's equity section;
|
|
105
|
+
* - `revenue`: the revenue (Umsatzerlöse); small companies may print only a gross result
|
|
106
|
+
* (Rohergebnis, § 276 HGB), and then it is null.
|
|
107
|
+
*/
|
|
108
|
+
export interface IKeyFigures {
|
|
109
|
+
totalAssets: string | null;
|
|
110
|
+
equity: string | null;
|
|
111
|
+
netIncome: string | null;
|
|
112
|
+
revenue: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The kind of a printed line:
|
|
117
|
+
* - `position`: a position of the outline of § 266 or § 275 HGB, or a line the statement adds to it;
|
|
118
|
+
* - `subtotal`: a printed sum of the lines above it, such as Summe Anlagevermögen, or an
|
|
119
|
+
* intermediate result of the income statement, such as Rohergebnis or Ergebnis nach Steuern;
|
|
120
|
+
* no total adds it again;
|
|
121
|
+
* - `total`: the total of a side of the balance sheet, or the income statement's net income;
|
|
122
|
+
* - `memo`: a "davon" line, part of the line above it and never added.
|
|
123
|
+
*/
|
|
124
|
+
export type TStatementLineKind = 'position' | 'subtotal' | 'total' | 'memo';
|
|
125
|
+
|
|
126
|
+
/** One printed line of a balance sheet or an income statement. */
|
|
127
|
+
export interface IStatementPosition {
|
|
128
|
+
/** the outline path of the line, its printed codes joined: `A.I.1`; null for a line without a code */
|
|
129
|
+
code: string | null;
|
|
130
|
+
/** the code as printed: `1.` or `a)` */
|
|
131
|
+
printedCode: string | null;
|
|
132
|
+
/** the line's text as printed, without its code */
|
|
133
|
+
label: string;
|
|
134
|
+
/** the depth in the outline, 0 for the top level */
|
|
135
|
+
level: number;
|
|
136
|
+
kind: TStatementLineKind;
|
|
137
|
+
/** the amount for the current year as a decimal string in the currency, null where none is printed */
|
|
138
|
+
current: string | null;
|
|
139
|
+
/** the same for the prior year */
|
|
140
|
+
prior: string | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface IBalanceSheet {
|
|
144
|
+
unit: IStatementUnit;
|
|
145
|
+
/** Aktiva, in printed order, with the total last */
|
|
146
|
+
assets: IStatementPosition[];
|
|
147
|
+
/** Passiva, in printed order, with the total last */
|
|
148
|
+
equityAndLiabilities: IStatementPosition[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The method of an income statement: `total_cost` (Gesamtkostenverfahren, § 275 Abs. 2 HGB) or
|
|
153
|
+
* `cost_of_sales` (Umsatzkostenverfahren, § 275 Abs. 3 HGB); null where its lines do not tell.
|
|
154
|
+
*/
|
|
155
|
+
export type TIncomeStatementMethod = 'total_cost' | 'cost_of_sales';
|
|
156
|
+
|
|
157
|
+
export interface IIncomeStatement {
|
|
158
|
+
unit: IStatementUnit;
|
|
159
|
+
method: TIncomeStatementMethod | null;
|
|
160
|
+
/**
|
|
161
|
+
* how the statement prints its expenses: `negative`, with a minus, so that its lines add up as
|
|
162
|
+
* printed, or `positive`, as amounts to subtract. The positions keep the amounts as printed.
|
|
163
|
+
*/
|
|
164
|
+
expenses: 'negative' | 'positive';
|
|
165
|
+
positions: IStatementPosition[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A statement under the HGB read from the HTML or the PDF a register shows. Everything in it was
|
|
170
|
+
* printed and adds up: each position with parts equals their sum, each side of the balance sheet its
|
|
171
|
+
* total, both totals each other, and the income statement's lines its net income. A prior year whose
|
|
172
|
+
* figures do not add up, and an income statement that does not, are left out with a note.
|
|
173
|
+
*/
|
|
174
|
+
export interface IHgbStatement {
|
|
175
|
+
standard: 'hgb';
|
|
176
|
+
currency: 'EUR';
|
|
177
|
+
periods: { current: IStatementPeriod; prior: IStatementPeriod | null };
|
|
178
|
+
keyFigures: { current: IKeyFigures; prior: IKeyFigures | null };
|
|
179
|
+
balanceSheet: IBalanceSheet;
|
|
180
|
+
/** null where the document publishes none, as small companies may (§ 326 Abs. 1 HGB) */
|
|
181
|
+
incomeStatement: IIncomeStatement | null;
|
|
182
|
+
/** what was left out and why, in English */
|
|
183
|
+
notes: string[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The period of an ESEF fact: an instant (`start` null) or a duration. */
|
|
187
|
+
export interface IEsefPeriod {
|
|
188
|
+
start: TIsoDate | null;
|
|
189
|
+
end: TIsoDate;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** One numeric fact of an ESEF report (`ix:nonFraction`). */
|
|
193
|
+
export interface IEsefFact {
|
|
194
|
+
/** the concept with the prefix the report declares for it: `ifrs-full:Revenue` */
|
|
195
|
+
concept: string;
|
|
196
|
+
/** the namespace of the concept */
|
|
197
|
+
namespace: string;
|
|
198
|
+
contextId: string;
|
|
199
|
+
period: IEsefPeriod;
|
|
200
|
+
/** the explicit dimension members of the context, dimension to member, both as written */
|
|
201
|
+
dimensions: Record<string, string>;
|
|
202
|
+
/** the unit: an ISO 4217 code such as `EUR`, `shares`, `pure`, or a ratio such as `EUR/shares` */
|
|
203
|
+
unit: string | null;
|
|
204
|
+
/** the value as a decimal string, scale and sign applied; null for a nil fact */
|
|
205
|
+
value: string | null;
|
|
206
|
+
/** the `decimals` attribute as written: `0`, `-3`, `INF` */
|
|
207
|
+
decimals: string | null;
|
|
208
|
+
/** the fact's `id`, where it has one */
|
|
209
|
+
id: string | null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A listed issuer's annual financial report in the European Single Electronic Format: the numeric
|
|
214
|
+
* facts its inline XBRL tags, and the key figures of its consolidated statements (IFRS).
|
|
215
|
+
*/
|
|
216
|
+
export interface IEsefStatement {
|
|
217
|
+
standard: 'esef';
|
|
218
|
+
/** the LEI the report's contexts identify the issuer with */
|
|
219
|
+
lei: string | null;
|
|
220
|
+
/** the path of the report in its package; null for a report read on its own */
|
|
221
|
+
reportPath: string | null;
|
|
222
|
+
/** the language the report declares */
|
|
223
|
+
language: string | null;
|
|
224
|
+
/** the currency of the key figures, ISO 4217 */
|
|
225
|
+
currency: string | null;
|
|
226
|
+
periods: { current: IStatementPeriod; prior: IStatementPeriod | null };
|
|
227
|
+
keyFigures: { current: IKeyFigures; prior: IKeyFigures | null };
|
|
228
|
+
facts: IEsefFact[];
|
|
229
|
+
/** what could not be read and why, in English */
|
|
230
|
+
notes: string[];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export type TParsedStatement = IHgbStatement | IEsefStatement;
|
|
234
|
+
|
|
235
|
+
/** Why a document was kept without a parsed statement. */
|
|
236
|
+
export interface IStatementParseFailure {
|
|
237
|
+
code: 'invalid_package' | 'unreadable' | 'no_text' | 'unparseable' | 'too_large';
|
|
238
|
+
message: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A statement as a caller keeps it: the filing, the document byte for byte, and what could be read
|
|
243
|
+
* from it. `parsed` is null rather than guessed: a document this entry point cannot read with
|
|
244
|
+
* certainty keeps its raw bytes, and `parseFailure` says why.
|
|
245
|
+
*/
|
|
246
|
+
export interface IFinancialStatement {
|
|
247
|
+
filing: IFiling;
|
|
248
|
+
raw: IRawStatementDocument;
|
|
249
|
+
parsed: TParsedStatement | null;
|
|
250
|
+
parseFailure: IStatementParseFailure | null;
|
|
251
|
+
/** when the document was obtained, milliseconds since the epoch */
|
|
252
|
+
obtainedAt: number;
|
|
253
|
+
/**
|
|
254
|
+
* the origin note the publisher marks the document with, for a copy to keep it: for the
|
|
255
|
+
* Unternehmensregister "Auszug aus dem Unternehmensregister" and the day the document was posted
|
|
256
|
+
* or deposited (Nutzungsbedingungen § 3 Abs. 5); null for other publishers
|
|
257
|
+
*/
|
|
258
|
+
notice: string | null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface IReadFinancialStatementOptions {
|
|
262
|
+
filing: IFilingInput;
|
|
263
|
+
bytes: Uint8Array;
|
|
264
|
+
/** the media type of `bytes`; parameters such as `; charset=utf-8` are ignored */
|
|
265
|
+
mediaType: string;
|
|
266
|
+
/** clock for `obtainedAt`; default `Date.now` */
|
|
267
|
+
now?: () => number;
|
|
268
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { descendants, elementChildren, getAttribute, hasClass, isElement, parseHtml, textOf, type THtmlElement } from './html.js';
|
|
3
|
+
import { normalizeSpaces } from './amounts.js';
|
|
4
|
+
import type { ILayoutCell, TLayoutBlock } from './layout.js';
|
|
5
|
+
|
|
6
|
+
type THtmlChildNode = plugins.parse5.DefaultTreeAdapterTypes.ChildNode;
|
|
7
|
+
|
|
8
|
+
const headingTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
9
|
+
const skippedTags = new Set(['head', 'script', 'style', 'template', 'noscript', 'svg', 'math', 'img', 'button', 'select', 'input']);
|
|
10
|
+
|
|
11
|
+
/** elements that end a line of text before and after them */
|
|
12
|
+
const blockTags = new Set([
|
|
13
|
+
'address', 'article', 'aside', 'blockquote', 'body', 'caption', 'center', 'dd', 'details', 'dialog', 'div',
|
|
14
|
+
'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'hr', 'li', 'main',
|
|
15
|
+
'nav', 'ol', 'p', 'pre', 'section', 'summary', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** colspan and rowspan as a statement may write them, kept to 1 to 50 */
|
|
19
|
+
const spanOf = (valueArg: string | undefined): number => {
|
|
20
|
+
const value = Number.parseInt(valueArg ?? '1', 10);
|
|
21
|
+
return Number.isFinite(value) ? Math.min(Math.max(value, 1), 50) : 1;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** the rows of a table without nested tables, every cell placed in the columns it covers */
|
|
25
|
+
const tableRows = (tableArg: THtmlElement): ILayoutCell[][] => {
|
|
26
|
+
const rowElements = descendants(tableArg).filter((elementArg) => elementArg.tagName === 'tr');
|
|
27
|
+
const occupied: Array<Set<number>> = rowElements.map(() => new Set<number>());
|
|
28
|
+
const rows: ILayoutCell[][] = [];
|
|
29
|
+
rowElements.forEach((rowElement, rowIndex) => {
|
|
30
|
+
const cells: ILayoutCell[] = [];
|
|
31
|
+
let column = 0;
|
|
32
|
+
for (const cellElement of elementChildren(rowElement)) {
|
|
33
|
+
if (cellElement.tagName !== 'td' && cellElement.tagName !== 'th') {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
while (occupied[rowIndex].has(column)) {
|
|
37
|
+
column++;
|
|
38
|
+
}
|
|
39
|
+
const colspan = spanOf(getAttribute(cellElement, 'colspan'));
|
|
40
|
+
const rowspan = spanOf(getAttribute(cellElement, 'rowspan'));
|
|
41
|
+
for (let spannedRow = rowIndex; spannedRow < Math.min(rowIndex + rowspan, rowElements.length); spannedRow++) {
|
|
42
|
+
for (let spannedColumn = column; spannedColumn < column + colspan; spannedColumn++) {
|
|
43
|
+
occupied[spannedRow].add(spannedColumn);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const text = normalizeSpaces(textOf(cellElement));
|
|
47
|
+
if (text !== '') {
|
|
48
|
+
cells.push({ text, first: column, last: column + colspan - 1 });
|
|
49
|
+
}
|
|
50
|
+
column += colspan;
|
|
51
|
+
}
|
|
52
|
+
if (cells.length > 0) {
|
|
53
|
+
rows.push(cells);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return rows;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The blocks of an HTML statement in document order: headings, lines of text and tables. Reads the
|
|
61
|
+
* publication itself (`div.publication_container`, as the Unternehmensregister and the Bundesanzeiger
|
|
62
|
+
* show it) where the page has one, else the whole body. A table that only lays out other tables is
|
|
63
|
+
* read through to them.
|
|
64
|
+
*/
|
|
65
|
+
export const htmlLayout = (htmlArg: string): TLayoutBlock[] => {
|
|
66
|
+
const document = parseHtml(htmlArg);
|
|
67
|
+
const elements = descendants(document);
|
|
68
|
+
const root =
|
|
69
|
+
elements.find((elementArg) => elementArg.tagName === 'div' && hasClass(elementArg, 'publication_container')) ??
|
|
70
|
+
elements.find((elementArg) => elementArg.tagName === 'body');
|
|
71
|
+
if (!root) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const blocks: TLayoutBlock[] = [];
|
|
75
|
+
let run: string[] = [];
|
|
76
|
+
const flush = () => {
|
|
77
|
+
const text = normalizeSpaces(run.join(' '));
|
|
78
|
+
run = [];
|
|
79
|
+
if (text !== '') {
|
|
80
|
+
blocks.push({ kind: 'text', text, heading: false });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const stack: Array<{ children: THtmlChildNode[]; index: number; block: boolean }> = [
|
|
84
|
+
{ children: root.childNodes, index: 0, block: true },
|
|
85
|
+
];
|
|
86
|
+
while (stack.length > 0) {
|
|
87
|
+
const frame = stack[stack.length - 1];
|
|
88
|
+
if (frame.index >= frame.children.length) {
|
|
89
|
+
stack.pop();
|
|
90
|
+
if (frame.block) {
|
|
91
|
+
flush();
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const child = frame.children[frame.index++];
|
|
96
|
+
if (!isElement(child)) {
|
|
97
|
+
if ('value' in child && child.nodeName === '#text') {
|
|
98
|
+
run.push(child.value);
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const tag = child.tagName;
|
|
103
|
+
if (skippedTags.has(tag)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (tag === 'br') {
|
|
107
|
+
flush();
|
|
108
|
+
} else if (headingTags.has(tag)) {
|
|
109
|
+
flush();
|
|
110
|
+
const text = normalizeSpaces(textOf(child));
|
|
111
|
+
if (text !== '') {
|
|
112
|
+
blocks.push({ kind: 'text', text, heading: true });
|
|
113
|
+
}
|
|
114
|
+
} else if (tag === 'table' && !descendants(child).some((elementArg) => elementArg.tagName === 'table')) {
|
|
115
|
+
flush();
|
|
116
|
+
const rows = tableRows(child);
|
|
117
|
+
if (rows.length > 0) {
|
|
118
|
+
blocks.push({ kind: 'table', rows });
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
const block = tag === 'table' || blockTags.has(tag);
|
|
122
|
+
if (block) {
|
|
123
|
+
flush();
|
|
124
|
+
}
|
|
125
|
+
stack.push({ children: child.childNodes, index: 0, block });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
flush();
|
|
129
|
+
return blocks;
|
|
130
|
+
};
|