├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc ├── README.md ├── jest.config.cjs ├── jsconfig.json ├── module.json ├── package-lock.json ├── package.json ├── src ├── apps │ ├── import-fultimator.ts │ └── import-pdf.ts ├── external │ ├── fultimator.ts │ └── project-fu.ts ├── main.ts ├── pdf │ ├── arbs │ │ ├── arbs.ts │ │ └── output.ts │ ├── lexers │ │ ├── pdf.ts │ │ └── token.ts │ └── parsers │ │ ├── accessoryPage.test.ts │ │ ├── accessoryPage.ts │ │ ├── armorPage.test.ts │ │ ├── armorPage.ts │ │ ├── beastiaryPage.test.ts │ │ ├── beastiaryPage.ts │ │ ├── consumablePage.test.ts │ │ ├── consumablePage.ts │ │ ├── fabula-ultma-pdf.test.ts │ │ ├── lib.ts │ │ ├── sheildPage.test.ts │ │ ├── shieldPage.ts │ │ ├── weaponPage.test.ts │ │ └── weaponPage.ts ├── style.css └── templates │ ├── import-fultimator.hbs │ └── import-pdf.hbs ├── tsconfig.json └── webpack.config.cjs /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2018, 5 | "sourceType": "module" 6 | }, 7 | "ignorePatterns": [ 8 | "/dist" 9 | ], 10 | "plugins": [ 11 | "@typescript-eslint", 12 | "prettier" 13 | ], 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:@typescript-eslint/eslint-recommended", 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:prettier/recommended" 19 | ], 20 | "rules": { 21 | "prettier/prettier": "warn", 22 | "@typescript-eslint/no-unused-vars": [ 23 | "error", 24 | { 25 | "argsIgnorePattern": "^_" 26 | } 27 | ] 28 | } 29 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: NodeJS with Webpack 2 | 3 | env: 4 | project_url: "https://github.com/${{github.repository}}" 5 | 6 | latest_manifest_url: "https://github.com/${{github.repository}}/releases/latest/download/module.json" 7 | 8 | release_module_url: "https://github.com/${{github.repository}}/releases/download/${{github.event.release.tag_name}}/fu-parser.zip" 9 | 10 | on: 11 | release: 12 | types: [published] 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Extract Version From Tag 24 | id: get_version 25 | uses: battila7/get-version-action@v2 26 | 27 | - name: Modify System Manifest With Release-Specific Values 28 | id: sub_manifest_link_version 29 | uses: cschleiden/replace-tokens@v1 30 | with: 31 | files: 'module.json' 32 | env: 33 | VERSION: ${{steps.get_version.outputs.version-without-v}} 34 | URL: ${{ env.project_url }} 35 | MANIFEST: ${{ env.latest_manifest_url }} 36 | DOWNLOAD: ${{ env.release_module_url }} 37 | 38 | - name: Use Node.js 39 | uses: actions/setup-node@v3 40 | with: 41 | node-version: 18.x 42 | 43 | - name: Build 44 | run: | 45 | npm install 46 | npx webpack 47 | 48 | - name: Create System Archive 49 | run: | 50 | zip -r fu-parser.zip dist/* 51 | 52 | - name: Update Release With Files 53 | id: create_version_release 54 | uses: ncipollo/release-action@v1 55 | with: 56 | allowUpdates: true 57 | name: ${{ github.event.release.name }} 58 | draft: ${{ github.event.release.draft }} 59 | prerelease: ${{ github.event.release.prerelease }} 60 | token: ${{ secrets.GITHUB_TOKEN }} 61 | artifacts: './module.json, ./fu-parser.zip' 62 | tag: ${{ github.event.release.tag_name }} 63 | body: ${{ github.event.release.body }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | data/ 3 | dist/ 4 | .vscode -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "trailingComma": "all", 5 | "useTabs": true 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fabula Ultima importer 2 | 3 | This [Foundry Virtual Tabletop](https://foundryvtt.com/) module is designed to work with [Unofficial Fabula Ultima System](https://github.com/League-of-Fabulous-Developers/FoundryVTT-Fabula-Ultima). It add buttons for importing data from: 4 | 1. an english pdf, watermarked by DriveThruRPG, of [Fabula Ultima](https://www.needgames.it/fabula-ultima-en/) by [Need Games](https://www.needgames.it/) 5 | 2. [Fultimator!](https://fabula-ultima-helper.web.app/) 6 | 7 | ## Installation 8 | 9 | 1. Open the Foundry Setup screen and navigate to the "Add-on Modules" tab. 10 | 2. Click the "Install Module" button on the bottom left. 11 | 3. Paste`https://github.com/xeqi/fu-parser/releases/latest/download/module.json` into the "Manifest URL:" field on the bottom. 12 | 4. Click "Install". 13 | 5. Launch a game world and go to Game Settings > Manage Modules and enable the module. 14 | 15 | ## PDF Import 16 | 17 | It will currently import and create Items and Actors for: 18 | * Armor, Accessories, Shields, Weapons 19 | * Consumable Items 20 | * Bestiary 21 | 22 | ### Instructions 23 | 24 | 1. Install the module and confirm it shows up in "Add-on Modules" on the setup page. 25 | 2. Launch a game world and go to Game Settings > Manage Modules and enable the module. 26 | 3. Once enabled go to the Game Settings menu and there will be a "FU Importer" heading with an "Import PDF" button. 27 | 4. Provide the pdf and determine the directory you would like to save extracted images. 28 | 5. Review the parse information provided. 29 | 6. Click on "Import Data" 30 | 31 | ### Debugging 32 | 33 | This project is still in its early days. Chances are your pdf will not work. Bad parses should provide a list of failed parses in the preview output. That information could be useful to compare against the parsers and determine what fix needs to occur. 34 | 35 | ## Fultimator Import 36 | 37 | Fultimator provides a json export file for npcs. This file can be used to import data into your world. 38 | 39 | NPCs with equipment require that an item with the same name exists in your foundry world. The built in ones should be available if the pdf import was used. 40 | 41 | Currently the following are unsupported: 42 | 1. Phases 43 | 2. Multipart 44 | 3. Notes 45 | 46 | ### Instructions 47 | ![FultimatorImporter](https://github.com/xeqi/fu-parser/assets/16845165/a088ea2f-6dca-4276-8468-3923b3c33afb) 48 | 1. Go to the Game Settings menu and there will be a "FU Importer" heading with an "Import Fultimator" button. 49 | 2. Paste the contents of the json export file from Fultimator 50 | 3. Review any error information provided. 51 | 4. Click on "Import Data" 52 | 53 | 54 | ## Contributing 55 | 56 | ### The Future 57 | Future additions will include: 58 | * Better parsing of Bestiary skills/spells descriptions to pull out damage information 59 | * Import Classes + Skills/Spells from PDF 60 | 61 | ### Code Overview 62 | 63 | This module mainly consists of two Foundry `Application`s that convert data from an import string to a Foundry `Item` or `Actor` based on the template definitions of [Unofficial Fabula Ultima System](https://github.com/League-of-Fabulous-Developers/FoundryVTT-Fabula-Ultima). 64 | 65 | For PDF parsing this module uses [PDF.js](https://mozilla.github.io/pdf.js/) to read the pdf and act like a lexer to create a `Token[]`. This `Token[]` is read by parsers built using applicative parser combinators. The resulting datastructure is used to import the information. 66 | 67 | ### Local Installation 68 | 69 | 1. Clone the repo to a local machine. 70 | 2. Run `npm install` to install the local dependencies 71 | 3. Run `npm run build` to create a `dist` folder containing packed js files 72 | 4. Install the contents of `dist` into a `modules/fu-parser` directory in your Foundry instance. 73 | - For local linux distribution run `ln -sr dist/ ~/.local/share/FoundryVTT/Data/modules/fu-parser` 74 | - For windows users run `mklink /D "C:\Users\\AppData\Local\FoundryVTT\Data\modules\fu-parser" "C:\path\to\dist"` 75 | 76 | ### Testing 77 | 78 | 1. Place a copy of your Fabula Ultima pdf at `data/Fabula_Ultima_-_Core_Rulebook.pdf`. 79 | 2. `npm install` if not previously ran for an installation. 80 | 3. `npm run test` 81 | 82 | The tests are a combination of property based generated tests for individual parsers, and functional tests against the pdf to confirm each pages parses into a single result. 83 | All Foundry VTT based functionality is untested. 84 | 85 | ### VS Code + Jest extension 86 | 87 | This module builds using ESNext modules so the Jest extension needs to be told to enable the correct options in node. To do so add to `.vscode/settings.json`: 88 | ``` 89 | "jest.nodeEnv": { 90 | "NODE_OPTIONS": "--experimental-vm-modules" 91 | } 92 | ``` 93 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest/presets/default-esm", 3 | resolver: "ts-jest-resolver", 4 | testEnvironment: "node", 5 | }; 6 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "Node", 5 | "target": "ES2020", 6 | "jsx": "react", 7 | "allowImportingTsExtensions": true, 8 | "strictNullChecks": true, 9 | "strictFunctionTypes": true 10 | }, 11 | "exclude": [ 12 | "node_modules", 13 | "**/node_modules/*" 14 | ] 15 | } -------------------------------------------------------------------------------- /module.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "fu-parser", 3 | "title": "Fabula Ultima PDF Parser", 4 | "description": "Parse the Fabula Ultima pdf for importing.", 5 | "authors": [ 6 | { 7 | "name": "xeqi" 8 | } 9 | ], 10 | "version": "#{VERSION}#", 11 | "compatibility": { 12 | "minimum": "10", 13 | "verified": "13" 14 | }, 15 | "relationships":{ 16 | "systems":[{ 17 | "id": "projectfu", 18 | "type": "system", 19 | "manifest": "https://github.com/League-of-Fabulous-Developers/FoundryVTT-Fabula-Ultima/releases/latest/download/system.json", 20 | "compatibility": { 21 | "verified": "2.1.0" 22 | } 23 | }] 24 | }, 25 | "esmodules": [ 26 | "main.js" 27 | ], 28 | "styles": [ 29 | "style.css" 30 | ], 31 | "url": "#{URL}#", 32 | "manifest": "#{MANIFEST}#", 33 | "download": "#{DOWNLOAD}#" 34 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fu-parser", 3 | "version": "0.0.1", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "build": "webpack", 8 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 9 | "prepare": "ts-patch install && typia patch" 10 | }, 11 | "author": "", 12 | "dependencies": { 13 | "pdfjs-dist": "^4.0.269", 14 | "typia": "^5.3.8" 15 | }, 16 | "devDependencies": { 17 | "@babel/preset-env": "^7.23.6", 18 | "@types/jest": "^29.5.11", 19 | "@types/jquery": "^3.5.29", 20 | "@typescript-eslint/eslint-plugin": "^6.14.0", 21 | "copy-webpack-plugin": "^11.0.0", 22 | "eslint": "^8.55.0", 23 | "eslint-config-prettier": "^9.1.0", 24 | "eslint-config-standard": "^17.1.0", 25 | "eslint-plugin-import": "^2.29.0", 26 | "eslint-plugin-n": "^16.4.0", 27 | "eslint-plugin-prettier": "^5.0.1", 28 | "eslint-plugin-promise": "^6.1.1", 29 | "fast-check": "^3.14.0", 30 | "jest": "^29.7.0", 31 | "prettier": "^3.1.1", 32 | "ts-jest": "^29.1.1", 33 | "ts-jest-resolver": "^2.0.1", 34 | "ts-loader": "^9.5.1", 35 | "ts-node": "^10.9.2", 36 | "ts-patch": "^3.1.1", 37 | "typescript": "^5.3.2", 38 | "webpack": "^5.89.0", 39 | "webpack-cli": "^5.1.4" 40 | } 41 | } -------------------------------------------------------------------------------- /src/apps/import-fultimator.ts: -------------------------------------------------------------------------------- 1 | import { json } from "typia"; 2 | import { 3 | Affinities, 4 | Attributes, 5 | Elements, 6 | Npc, 7 | NpcArmor, 8 | Weapon, 9 | Player, 10 | PCBond, 11 | WeaponCategory, 12 | PCWeapon, 13 | PCShield, 14 | PCArmor, 15 | PCAccessory, 16 | } from "../external/fultimator"; 17 | import { ATTR, CATEGORY, FUActor, FUItem, FUActorPC } from "../external/project-fu"; 18 | import { DamageType } from "../pdf/parsers/lib"; 19 | // import { Accessory } from "../pdf/parsers/accessoryPage"; 20 | 21 | interface BondInput { 22 | name: string; 23 | admInf: "Admiration" | "Inferiority" | ""; 24 | loyMis: "Loyalty" | "Mistrust" | ""; 25 | affHat: "Affection" | "Hatred" | ""; 26 | strength: number; 27 | } 28 | 29 | const CATEGORY_MAPPING: Record = { 30 | Arcane: "arcane", 31 | Bow: "bow", 32 | Flail: "flail", 33 | Firearm: "firearm", 34 | Spear: "spear", 35 | Thrown: "thrown", 36 | Heavy: "heavy", 37 | Dagger: "dagger", 38 | Brawling: "brawling", 39 | Sword: "sword", 40 | }; 41 | 42 | const AFF_MAPPING: Record = { 43 | vu: -1, 44 | no: 0, 45 | rs: 1, 46 | im: 2, 47 | ab: 3, 48 | }; 49 | 50 | const STAT_MAPPING: Record = { 51 | dexterity: "dex", 52 | might: "mig", 53 | insight: "ins", 54 | will: "wlp", 55 | }; 56 | 57 | const ELEMENTS_MAPPING: Record = { 58 | physical: "physical", 59 | wind: "air", 60 | bolt: "bolt", 61 | dark: "dark", 62 | earth: "earth", 63 | fire: "fire", 64 | ice: "ice", 65 | light: "light", 66 | poison: "poison", 67 | }; 68 | 69 | const lookupAffinity = (affinity?: Affinities) => { 70 | return affinity ? AFF_MAPPING[affinity] : 0; 71 | }; 72 | 73 | const getName = (name?: string, fallback = "Unnamed") => name?.trim() || fallback; 74 | 75 | const importFultimatorWeapon = async (data: PCWeapon) => { 76 | const type = data.type; 77 | const category = data.category; 78 | const isRanged = data.ranged; 79 | const payload: FUItem = { 80 | type: "weapon" as const, 81 | name: data.name !== "" ? data.name : "Unnamed data", 82 | system: { 83 | attributes: { 84 | primary: { value: STAT_MAPPING[data.att1] }, 85 | secondary: { value: STAT_MAPPING[data.att2] }, 86 | }, 87 | accuracy: { value: data.prec }, 88 | damage: { value: data.damage }, 89 | type: { value: isRanged ? "ranged" : "melee" }, 90 | damageType: { value: ELEMENTS_MAPPING[type] }, 91 | description: "", 92 | isBehavior: false, 93 | cost: { value: data.cost }, 94 | weight: { value: 1 }, 95 | quality: { value: data.quality }, 96 | isMartial: { value: data.martial }, 97 | category: { value: CATEGORY_MAPPING[category] }, 98 | hands: { value: data.hands === 1 ? "one-handed" : "two-handed" }, 99 | isCustomWeapon: { value: false }, 100 | }, 101 | }; 102 | const item = await Item.create(payload); 103 | console.log("Item created:", item); 104 | }; 105 | 106 | const importFultimatorShield = async (data: PCShield) => { 107 | const defMod = data.defModifier ?? 0; 108 | const mDefMod = data.mDefModifier ?? 0; 109 | const initMod = data.initModifier ?? 0; 110 | const payload: FUItem = { 111 | type: "shield" as const, 112 | name: data.name !== "" ? data.name : "Unnamed data", 113 | system: { 114 | description: "", 115 | cost: { value: data.cost }, 116 | isMartial: { value: data.martial }, 117 | quality: { value: data.quality }, 118 | isEquipped: { value: false, slot: "" }, 119 | def: { value: data.def + defMod }, 120 | mdef: { value: data.mdef + mDefMod }, 121 | init: { value: data.init + initMod }, 122 | isBehavior: false, 123 | weight: { value: 1 }, 124 | }, 125 | }; 126 | const item = await Item.create(payload); 127 | console.log("Item created:", item); 128 | }; 129 | 130 | const importFultimatorArmor = async (data: PCArmor) => { 131 | const defMod = data.defModifier ?? 0; 132 | const mDefMod = data.mDefModifier ?? 0; 133 | const initMod = data.initModifier ?? 0; 134 | const payload: FUItem = { 135 | type: "armor" as const, 136 | name: data.name !== "" ? data.name : "Unnamed Armor", 137 | system: { 138 | def: { value: data.def + defMod, attribute: "dex" }, 139 | mdef: { value: data.mdef + mDefMod, attribute: "ins" }, 140 | init: { value: data.init + initMod }, 141 | description: "", 142 | isBehavior: false, 143 | cost: { value: data.cost }, 144 | weight: { value: 1 }, 145 | quality: { value: data.quality }, 146 | isMartial: { value: data.martial }, 147 | }, 148 | }; 149 | const item = await Item.create(payload); 150 | console.log("Armor item created:", item); 151 | }; 152 | 153 | const importFultimatorAccessory = async (data: PCAccessory) => { 154 | const defMod = data.defModifier ?? 0; 155 | const mDefMod = data.mDefModifier ?? 0; 156 | const initMod = data.initModifier ?? 0; 157 | const payload: FUItem = { 158 | type: "shield" as const, 159 | name: data.name !== "" ? data.name : "Unnamed data", 160 | system: { 161 | def: { value: defMod | 0 }, 162 | mdef: { value: mDefMod | 0 }, 163 | init: { value: initMod | 0 }, 164 | description: "", 165 | isBehavior: false, 166 | cost: { value: data.cost }, 167 | weight: { value: 1 }, 168 | quality: { value: data.quality }, 169 | isMartial: { value: false }, 170 | }, 171 | }; 172 | const item = await Item.create(payload); 173 | console.log("Item created:", item); 174 | }; 175 | 176 | const importFultimatorPC = async (data: Player, preferCompendium: boolean = true) => { 177 | typeof data.id === "number" ? data.id.toString() : data.id; 178 | 179 | const transformBondData = (bonds: PCBond[]): BondInput[] => { 180 | return bonds.map((bond) => { 181 | const nonEmptyFieldsCount = [ 182 | bond.admiration, 183 | bond.inferiority, 184 | bond.loyality, 185 | bond.mistrust, 186 | bond.affection, 187 | bond.hatred, 188 | ].filter(Boolean).length; 189 | 190 | const calculatedStrength = Math.min(nonEmptyFieldsCount, 4); 191 | 192 | return { 193 | name: bond.name || "", 194 | admInf: bond.admiration ? "Admiration" : bond.inferiority ? "Inferiority" : "", 195 | loyMis: bond.loyality ? "Loyalty" : bond.mistrust ? "Mistrust" : "", 196 | affHat: bond.affection ? "Affection" : bond.hatred ? "Hatred" : "", 197 | strength: calculatedStrength, 198 | }; 199 | }); 200 | }; 201 | 202 | const payload: FUActorPC = { 203 | system: { 204 | level: { value: data.lvl }, 205 | resources: { 206 | hp: { 207 | value: data.stats.hp.current, 208 | max: 0, 209 | min: 0, 210 | bonus: data.modifiers.hp, 211 | }, 212 | mp: { 213 | value: data.stats.mp.current, 214 | max: 0, 215 | min: 0, 216 | bonus: data.modifiers.mp, 217 | }, 218 | ip: { 219 | value: data.stats.ip.current, 220 | max: 0, 221 | min: 0, 222 | bonus: data.modifiers.ip, 223 | }, 224 | fp: { value: data.info.fabulapoints }, 225 | zenit: { value: data.info.zenit }, 226 | exp: { value: data.info.exp }, 227 | identity: { name: data.info.identity || "" }, 228 | pronouns: { name: data.info.pronouns || "" }, 229 | theme: { name: data.info.theme || "" }, 230 | origin: { name: data.info.origin || "" }, 231 | bonds: transformBondData(data.info.bonds || []), 232 | }, 233 | affinities: { 234 | physical: { 235 | base: lookupAffinity(data.affinities?.physical), 236 | current: lookupAffinity(data.affinities?.physical), 237 | bonus: 0 as const, 238 | }, 239 | air: { 240 | base: lookupAffinity(data.affinities?.wind), 241 | current: lookupAffinity(data.affinities?.wind), 242 | bonus: 0 as const, 243 | }, 244 | bolt: { 245 | base: lookupAffinity(data.affinities?.bolt), 246 | current: lookupAffinity(data.affinities?.bolt), 247 | bonus: 0 as const, 248 | }, 249 | dark: { 250 | base: lookupAffinity(data.affinities?.dark), 251 | current: lookupAffinity(data.affinities?.dark), 252 | bonus: 0 as const, 253 | }, 254 | earth: { 255 | base: lookupAffinity(data.affinities?.earth), 256 | current: lookupAffinity(data.affinities?.earth), 257 | bonus: 0 as const, 258 | }, 259 | fire: { 260 | base: lookupAffinity(data.affinities?.fire), 261 | current: lookupAffinity(data.affinities?.fire), 262 | bonus: 0 as const, 263 | }, 264 | ice: { 265 | base: lookupAffinity(data.affinities?.ice), 266 | current: lookupAffinity(data.affinities?.ice), 267 | bonus: 0 as const, 268 | }, 269 | light: { 270 | base: lookupAffinity(data.affinities?.light), 271 | current: lookupAffinity(data.affinities?.light), 272 | bonus: 0 as const, 273 | }, 274 | poison: { 275 | base: lookupAffinity(data.affinities?.poison), 276 | current: lookupAffinity(data.affinities?.poison), 277 | bonus: 0 as const, 278 | }, 279 | }, 280 | attributes: { 281 | dex: { base: data.attributes.dexterity, current: data.attributes.dexterity, bonus: 0 as const }, 282 | ins: { base: data.attributes.insight, current: data.attributes.insight, bonus: 0 as const }, 283 | mig: { base: data.attributes.might, current: data.attributes.might, bonus: 0 as const }, 284 | wlp: { base: data.attributes.willpower, current: data.attributes.willpower, bonus: 0 as const }, 285 | }, 286 | derived: { 287 | init: { 288 | value: 0, 289 | bonus: data.modifiers.init, 290 | }, 291 | def: { 292 | value: 0, 293 | bonus: data.modifiers.def, 294 | }, 295 | mdef: { 296 | value: 0, 297 | bonus: data.modifiers.mdef, 298 | }, 299 | }, 300 | bonuses: { 301 | accuracy: { 302 | accuracyCheck: 0, 303 | accuracyMelee: data.modifiers.meleePrec, 304 | accuracyRanged: data.modifiers.rangedPrec, 305 | magicCheck: data.modifiers.magicPrec, 306 | }, 307 | damage: { 308 | melee: 0, 309 | ranged: 0, 310 | spell: 0, 311 | }, 312 | }, 313 | description: data.info.description || "", 314 | }, 315 | type: "character", 316 | name: data.name != "" ? data.name : "Unnamed NPC", 317 | }; 318 | 319 | const classItems = await Promise.all( 320 | (data.classes || []).map(async (cls): Promise => { 321 | const className = getName(cls.name, "Unnamed Class"); 322 | if (preferCompendium) { 323 | const compendium = game.packs.get("projectfu.classes"); 324 | if (compendium) { 325 | const entry = await compendium.getIndex({ fields: ["name"] }); 326 | const match = entry.find((e: { name: unknown }) => e.name === className); 327 | if (match) { 328 | const doc = await compendium.getDocument(match._id); 329 | const item = doc.toObject(); 330 | item.system.level.value = cls.lvl; // Preserve level 331 | return item as FUItem; 332 | } 333 | } 334 | } 335 | // Fallback if not found in compendium 336 | return { 337 | name: className, 338 | system: { 339 | level: { value: cls.lvl }, 340 | benefits: { 341 | resources: { 342 | hp: { value: cls.benefits.hpplus }, 343 | mp: { value: cls.benefits.mpplus }, 344 | ip: { value: cls.benefits.ipplus }, 345 | }, 346 | martials: { 347 | melee: { value: cls.benefits.martials.melee }, 348 | ranged: { value: cls.benefits.martials.ranged }, 349 | armor: { value: cls.benefits.martials.armor }, 350 | shields: { value: cls.benefits.martials.shields }, 351 | }, 352 | rituals: { 353 | arcanism: { value: cls.benefits.rituals.arcanism }, 354 | chimerism: { value: cls.benefits.rituals.chimerism }, 355 | elementalism: { value: cls.benefits.rituals.elementalism }, 356 | entropism: { value: cls.benefits.rituals.entropism }, 357 | ritualism: { value: cls.benefits.rituals.ritualism }, 358 | spiritism: { value: cls.benefits.rituals.spiritism }, 359 | }, 360 | }, 361 | description: "", 362 | }, 363 | type: "class" as const, 364 | }; 365 | }), 366 | ); 367 | 368 | const skillItems = await Promise.all( 369 | (data.classes || []).flatMap((cls) => 370 | (cls.skills || []) 371 | .filter((skill) => skill.currentLvl > 0) 372 | .map(async (skill): Promise => { 373 | const skillName = getName(skill.skillName, "Unnamed Skill"); 374 | if (preferCompendium) { 375 | const compendium = game.packs.get("projectfu.skills"); 376 | if (compendium) { 377 | const index = await compendium.getIndex({ fields: ["name"] }); 378 | const match = index.find((e: { name: unknown }) => e.name === skillName); 379 | if (match) { 380 | const doc = await compendium.getDocument(match._id); 381 | const item = doc.toObject(); 382 | item.system.level = { 383 | value: skill.currentLvl, 384 | max: skill.maxLvl, 385 | }; 386 | return item as FUItem; 387 | } 388 | } 389 | } 390 | 391 | // Fallback if not found in compendium 392 | return { 393 | type: "skill", 394 | name: skillName, 395 | system: { 396 | description: skill.description, 397 | level: { value: skill.currentLvl, max: skill.maxLvl }, 398 | }, 399 | }; 400 | }), 401 | ), 402 | ); 403 | 404 | const heroicItems = await Promise.all( 405 | (data.classes || []).map(async (cls): Promise => { 406 | if (!cls.heroic || cls.lvl < 10) return null; 407 | const heroicName = getName(cls.heroic.name, "Unnamed Heroic Skill"); 408 | if (preferCompendium) { 409 | const compendium = game.packs.get("projectfu.heroic-skills"); 410 | if (compendium) { 411 | const index = await compendium.getIndex({ fields: ["name"] }); 412 | const match = index.find((e: { name: string }) => e.name === heroicName); 413 | if (match) { 414 | const doc = await compendium.getDocument(match._id); 415 | const item = doc.toObject(); 416 | item.system.subtype = { value: "skill" }; 417 | item.system.class = { value: cls.name }; 418 | return item as FUItem; 419 | } 420 | } 421 | } 422 | // Fallback if not found in compendium 423 | return { 424 | type: "heroic", 425 | name: heroicName, 426 | system: { 427 | subtype: { value: "skill" }, 428 | class: { value: cls.name }, 429 | description: cls.heroic.description, 430 | }, 431 | }; 432 | }), 433 | ).then((results) => results.filter(Boolean) as FUItem[]); 434 | 435 | const spellItems = await Promise.all( 436 | (data.classes || []).map(async (cls): Promise => { 437 | if (!cls.spells || cls.spells.length === 0) return []; 438 | const items = await Promise.all( 439 | (cls.spells || []).map(async (pcSpell): Promise => { 440 | const spellName = getName(pcSpell.name, "Unnamed Spell"); 441 | if (preferCompendium) { 442 | const compendium = game.packs.get("projectfu.spells"); 443 | if (compendium) { 444 | const index = await compendium.getIndex({ fields: ["name"] }); 445 | const match = index.find((e: { name: string }) => e.name === spellName); 446 | if (match) { 447 | const doc = await compendium.getDocument(match._id); 448 | const item = doc.toObject(); 449 | return item as FUItem; 450 | } 451 | } 452 | } 453 | // Fallback if not found in compendium 454 | return { 455 | type: "spell", 456 | name: spellName, 457 | system: { 458 | mpCost: { value: pcSpell.mp.toString() }, 459 | maxTargets: { value: pcSpell.maxTargets.toString() }, 460 | target: { value: pcSpell.targetDesc.toString() }, 461 | duration: { value: pcSpell.duration }, 462 | isOffensive: { value: pcSpell.isOffensive === true }, 463 | hasRoll: { value: pcSpell.isOffensive === true }, 464 | rollInfo: 465 | pcSpell.isOffensive === true 466 | ? { 467 | attributes: { 468 | primary: { value: STAT_MAPPING[pcSpell.attr1] }, 469 | secondary: { value: STAT_MAPPING[pcSpell.attr2] }, 470 | }, 471 | accuracy: { value: 0 }, 472 | } 473 | : undefined, 474 | description: pcSpell.description, 475 | isBehavior: false, 476 | weight: { value: 1 }, 477 | quality: { value: "" }, 478 | }, 479 | }; 480 | }), 481 | ); 482 | return items.filter((item): item is FUItem => item !== null); 483 | }), 484 | ).then((results) => results.flat()); 485 | 486 | const noteItems: FUItem[] = (data.notes || []).flatMap((note) => { 487 | const baseNote: FUItem = { 488 | name: getName(note.name, "Unnamed Note"), 489 | system: { 490 | description: note.description, 491 | isBehavior: false, 492 | weight: { value: 1 }, 493 | hasClock: { value: false }, 494 | hasRoll: { value: false }, 495 | }, 496 | type: "miscAbility" as const, 497 | }; 498 | const clockItems: FUItem[] = (note.clocks || []).map((clock) => { 499 | const clockName = getName(clock.name, "Unnamed Clock"); 500 | return { 501 | name: clockName, 502 | system: { 503 | description: clockName, 504 | isBehavior: false, 505 | isFavored: { value: true }, 506 | weight: { value: 1 }, 507 | hasClock: { value: true }, 508 | progress: { 509 | name: clockName, 510 | current: 0, 511 | step: 0, 512 | max: clock.sections || 0, 513 | }, 514 | hasRoll: { value: false }, 515 | }, 516 | type: "miscAbility" as const, 517 | }; 518 | }); 519 | return [baseNote, ...clockItems]; 520 | }); 521 | 522 | const weaponItems = (data.weapons || []).map((weapon): FUItem => { 523 | const type = weapon.type; 524 | const category = weapon.category; 525 | 526 | return { 527 | type: "weapon" as const, 528 | name: weapon.name !== "" ? weapon.name : "Unnamed Weapon", 529 | system: { 530 | attributes: { 531 | primary: { value: STAT_MAPPING[weapon.att1] }, 532 | secondary: { value: STAT_MAPPING[weapon.att2] }, 533 | }, 534 | accuracy: { value: weapon.prec }, 535 | damage: { value: weapon.damage }, 536 | type: { value: weapon.ranged ? "ranged" : "melee" }, 537 | damageType: { value: ELEMENTS_MAPPING[type] }, 538 | description: "", 539 | isBehavior: false, 540 | cost: { value: weapon.cost }, 541 | weight: { value: 1 }, 542 | quality: { value: weapon.quality }, 543 | isMartial: { value: weapon.martial }, 544 | category: { value: CATEGORY_MAPPING[category] }, 545 | hands: { value: weapon.hands === 1 ? "one-handed" : "two-handed" }, 546 | isCustomWeapon: { value: false }, 547 | }, 548 | }; 549 | }); 550 | 551 | const armorItems = (data.armor || []).map((armor): FUItem => { 552 | const defMod = armor.defModifier ?? 0; 553 | const mDefMod = armor.mDefModifier ?? 0; 554 | const initMod = armor.initModifier ?? 0; 555 | return { 556 | type: "armor" as const, 557 | name: armor.name !== "" ? armor.name : "Unnamed Armor", 558 | system: { 559 | def: { value: armor.def + defMod }, 560 | mdef: { value: armor.mdef + mDefMod }, 561 | init: { value: armor.init + initMod }, 562 | description: "", 563 | isBehavior: false, 564 | cost: { value: armor.cost }, 565 | weight: { value: 1 }, 566 | quality: { value: armor.quality }, 567 | isMartial: { value: armor.martial }, 568 | }, 569 | }; 570 | }); 571 | 572 | const shieldItems = (data.shields || []).map((shield): FUItem => { 573 | const defMod = shield.defModifier ?? 0; 574 | const mDefMod = shield.mDefModifier ?? 0; 575 | const initMod = shield.initModifier ?? 0; 576 | return { 577 | type: "shield" as const, 578 | name: shield.name !== "" ? shield.name : "Unnamed Shield", 579 | system: { 580 | def: { value: shield.def + defMod }, 581 | mdef: { value: shield.mdef + mDefMod }, 582 | init: { value: shield.init + initMod }, 583 | description: "", 584 | isBehavior: false, 585 | cost: { value: shield.cost }, 586 | weight: { value: 1 }, 587 | quality: { value: shield.quality }, 588 | isMartial: { value: shield.martial }, 589 | }, 590 | }; 591 | }); 592 | 593 | const accessoryItems = (data.accessories || []).map((accessory): FUItem => { 594 | const defMod = accessory.defModifier ?? 0; 595 | const mDefMod = accessory.mDefModifier ?? 0; 596 | const initMod = accessory.initModifier ?? 0; 597 | return { 598 | type: "accessory" as const, 599 | name: accessory.name !== "" ? accessory.name : "Unnamed accessory", 600 | system: { 601 | def: { value: defMod }, 602 | mdef: { value: mDefMod }, 603 | init: { value: initMod }, 604 | description: "", 605 | isBehavior: false, 606 | cost: { value: accessory.cost }, 607 | weight: { value: 1 }, 608 | quality: { value: accessory.quality }, 609 | isMartial: { value: false }, 610 | }, 611 | }; 612 | }); 613 | 614 | const quirkItems: FUItem[] = (Array.isArray(data.quirk) ? data.quirk : []).map((quirk) => ({ 615 | type: "optionalFeature" as const, 616 | name: quirk.name !== "" ? quirk.name : "Unnamed quirk", 617 | system: { 618 | optionalType: "projectfu.quirk", 619 | data: { description: (quirk.description || "") + "
" + (quirk.effect || "") }, 620 | }, 621 | })); 622 | 623 | // Create the actor and add items 624 | const actor = await Actor.create(payload); 625 | await actor.createEmbeddedDocuments("Item", [ 626 | ...weaponItems, 627 | ...armorItems, 628 | ...shieldItems, 629 | ...accessoryItems, 630 | ...classItems, 631 | ...skillItems, 632 | ...heroicItems, 633 | ...spellItems, 634 | ...noteItems, 635 | ...quirkItems, 636 | ]); 637 | 638 | await actor.update({ 639 | "system.resources.hp.value": actor.system.resources.hp.max, 640 | "system.resources.mp.value": actor.system.resources.mp.max, 641 | }); 642 | }; 643 | 644 | const importFultimatorNPC = async (data: Npc) => { 645 | typeof data.id === "number" ? data.id.toString() : data.id; 646 | const phases = typeof data.phases === "string" ? Number(data.phases) : data.phases; 647 | let mainHandFree = true; 648 | let offHandFree = true; 649 | 650 | const equipment = [data.armor, data.shield, ...(data.weaponattacks?.map((e) => e.weapon) || [])] 651 | .filter((e): e is NpcArmor | Weapon => e != null) 652 | .map((e) => { 653 | const item = game.items.find((f) => f.name.toLowerCase() === e.name.toLowerCase()) as FUItem; 654 | if (item) { 655 | const data = duplicate(item); 656 | const itemType = data.type; 657 | if (itemType === "weapon") { 658 | if (mainHandFree) { 659 | data.system.isEquipped = { slot: "mainHand", value: true }; 660 | mainHandFree = false; 661 | if (data.system.hands.value == "two-handed") { 662 | offHandFree = false; 663 | } 664 | } else { 665 | if (offHandFree && data.system.hands.value == "one-handed") { 666 | data.system.isEquipped = { slot: "offHand", value: true }; 667 | offHandFree = false; 668 | } 669 | } 670 | } else if (itemType === "shield") { 671 | if (offHandFree) { 672 | data.system.isEquipped = { slot: "offHand", value: true }; 673 | offHandFree = false; 674 | } 675 | } else if (itemType == "accessory" || itemType == "armor") { 676 | data.system.isEquipped = { slot: itemType, value: true }; 677 | } 678 | return data; 679 | } else { 680 | console.log("Not Found", e); 681 | } 682 | }) 683 | .filter((d): d is FUItem => d !== undefined); 684 | 685 | const payload: FUActor = { 686 | system: { 687 | level: { value: data.lvl }, 688 | resources: { 689 | hp: { 690 | value: 0, 691 | max: 0, 692 | min: 0, 693 | bonus: data.extra && data.extra.hp ? Number(data.extra.hp) : 0, 694 | }, 695 | mp: { 696 | value: 0, 697 | max: 0, 698 | min: 0, 699 | bonus: data.extra && data.extra.mp ? Number(data.extra.mp) : 0, 700 | }, 701 | ip: { value: 6, max: 6, min: 0 }, 702 | fp: { value: 3 }, 703 | }, 704 | affinities: { 705 | physical: { 706 | base: lookupAffinity(data.affinities.physical), 707 | current: lookupAffinity(data.affinities.physical), 708 | bonus: 0 as const, 709 | }, 710 | air: { 711 | base: lookupAffinity(data.affinities.wind), 712 | current: lookupAffinity(data.affinities.wind), 713 | bonus: 0 as const, 714 | }, 715 | bolt: { 716 | base: lookupAffinity(data.affinities.bolt), 717 | current: lookupAffinity(data.affinities.bolt), 718 | bonus: 0 as const, 719 | }, 720 | dark: { 721 | base: lookupAffinity(data.affinities.dark), 722 | current: lookupAffinity(data.affinities.dark), 723 | bonus: 0 as const, 724 | }, 725 | earth: { 726 | base: lookupAffinity(data.affinities.earth), 727 | current: lookupAffinity(data.affinities.earth), 728 | bonus: 0 as const, 729 | }, 730 | fire: { 731 | base: lookupAffinity(data.affinities.fire), 732 | current: lookupAffinity(data.affinities.fire), 733 | bonus: 0 as const, 734 | }, 735 | ice: { 736 | base: lookupAffinity(data.affinities.ice), 737 | current: lookupAffinity(data.affinities.ice), 738 | bonus: 0 as const, 739 | }, 740 | light: { 741 | base: lookupAffinity(data.affinities.light), 742 | current: lookupAffinity(data.affinities.light), 743 | bonus: 0 as const, 744 | }, 745 | poison: { 746 | base: lookupAffinity(data.affinities.poison), 747 | current: lookupAffinity(data.affinities.poison), 748 | bonus: 0 as const, 749 | }, 750 | }, 751 | attributes: { 752 | dex: { base: data.attributes.dexterity, current: data.attributes.dexterity, bonus: 0 as const }, 753 | ins: { base: data.attributes.insight, current: data.attributes.insight, bonus: 0 as const }, 754 | mig: { base: data.attributes.might, current: data.attributes.might, bonus: 0 as const }, 755 | wlp: { base: data.attributes.will, current: data.attributes.will, bonus: 0 as const }, 756 | }, 757 | derived: { 758 | init: { 759 | value: 0, 760 | bonus: 761 | (data.extra && data.extra.init ? 4 : 0) + 762 | (data.extra && data.extra?.extrainit ? Number(data.extra.extrainit) : 0), 763 | }, 764 | def: { value: 0, bonus: (data.extra && data.extra.def) || 0 }, 765 | mdef: { value: 0, bonus: (data.extra && data.extra.mDef) || 0 }, 766 | accuracy: { value: 0, bonus: data.extra && data.extra.precision ? 3 : 0 }, 767 | magic: { value: 0, bonus: data.extra && data.extra.magic ? 3 : 0 }, 768 | }, 769 | traits: { value: data.traits || "" }, 770 | species: { value: data.species.toLowerCase() }, 771 | useEquipment: { value: equipment.length != 0 }, 772 | villain: { value: data.villain || "" }, 773 | phases: { value: phases || 0 }, 774 | multipart: { value: data.multipart || "" }, 775 | isElite: { value: data.rank == "elite" }, 776 | isChampion: { value: data.rank && /champion/.test(data.rank) ? Number(data.rank.slice(-1)) : 1 }, 777 | isCompanion: { value: data.rank == "companion" }, 778 | study: { value: 0 as const }, 779 | description: data.description || "", 780 | }, 781 | type: "npc", 782 | name: data.name != "" ? data.name : "Unnamed NPC", 783 | }; 784 | 785 | const actor = await Actor.create(payload); 786 | 787 | const attackItems = data.attacks.map((attack): FUItem => { 788 | return { 789 | type: "basic" as const, 790 | name: attack.name != "" ? attack.name : "Unnamed Attack", 791 | system: { 792 | attributes: { 793 | primary: { value: STAT_MAPPING[attack.attr1] }, 794 | secondary: { value: STAT_MAPPING[attack.attr2] }, 795 | }, 796 | accuracy: { 797 | value: 798 | Math.floor(data.lvl / 10) + 799 | (data.rank == "companion" ? data.lvl || 1 : 0) + 800 | (attack.flathit ? Number(attack.flathit) : 0), 801 | }, 802 | damage: { 803 | value: 804 | Math.floor(data.lvl / 20) * 5 + 805 | 5 + 806 | (attack.extraDamage ? 5 : 0) + 807 | (data.rank == "companion" ? data.lvl || 1 : 0) + 808 | (attack.flatdmg ? Number(attack.flatdmg) : 0), 809 | }, 810 | type: { value: attack.range == "distance" ? "ranged" : "melee" }, 811 | damageType: { value: ELEMENTS_MAPPING[attack.type] }, 812 | quality: { value: attack.special.join(" ") }, 813 | isBehavior: false, 814 | weight: { value: 1 }, 815 | description: "", 816 | }, 817 | }; 818 | }); 819 | 820 | const weaponAttackItems = (data.weaponattacks || []).map((attack): FUItem => { 821 | const type = attack.type ?? attack.weapon.type ?? "physical"; 822 | return { 823 | type: "basic" as const, 824 | name: attack.name != "" ? attack.name : "Unnamed Weapon Attack", 825 | system: { 826 | attributes: { 827 | primary: { value: STAT_MAPPING[attack.weapon.att1] }, 828 | secondary: { value: STAT_MAPPING[attack.weapon.att2] }, 829 | }, 830 | accuracy: { 831 | value: 832 | Math.floor(data.lvl / 10) + 833 | attack.weapon.prec + 834 | (data.rank == "companion" ? data.lvl || 1 : 0) + 835 | (attack.flathit ? Number(attack.flathit) : 0), 836 | }, 837 | damage: { 838 | value: 839 | Math.floor(data.lvl / 20) * 5 + 840 | attack.weapon.damage + 841 | (attack.extraDamage ? 5 : 0) + 842 | (attack.flatdmg ? Number(attack.flatdmg) : 0), 843 | }, 844 | type: { value: attack.weapon.range == "distance" ? "ranged" : "melee" }, 845 | damageType: { value: ELEMENTS_MAPPING[type] }, 846 | description: "", 847 | isBehavior: false, 848 | weight: { value: 1 }, 849 | quality: { value: attack.special.join(" ") }, 850 | }, 851 | }; 852 | }); 853 | 854 | const spellItems = (data.spells || []).map((spell) => { 855 | return { 856 | type: "spell" as const, 857 | name: spell.name != "" ? spell.name : "Unnamed Spell", 858 | system: { 859 | mpCost: { value: spell.mp }, 860 | target: { value: spell.target }, 861 | duration: { value: spell.duration }, 862 | isOffensive: { value: spell.type == "offensive" }, 863 | hasRoll: { value: spell.type == "offensive" }, 864 | rollInfo: 865 | spell.type == "offensive" 866 | ? { 867 | attributes: { 868 | primary: { value: STAT_MAPPING[spell.attr1] }, 869 | secondary: { value: STAT_MAPPING[spell.attr2] }, 870 | }, 871 | accuracy: { 872 | value: Math.floor(data.lvl / 10) + (data.rank == "companion" ? data.lvl || 1 : 0), 873 | }, 874 | } 875 | : undefined, 876 | description: spell.effect, 877 | isBehavior: false, 878 | weight: { value: 1 }, 879 | quality: { value: "" }, 880 | }, 881 | }; 882 | }); 883 | 884 | const otherActionItems = (data.actions || []).map((oa): FUItem => { 885 | return { 886 | name: oa.name != "" ? oa.name : "Unnamed Other Action", 887 | system: { 888 | description: oa.effect, 889 | isBehavior: false, 890 | weight: { value: 1 }, 891 | hasClock: { value: false }, 892 | hasRoll: { value: false }, 893 | }, 894 | type: "miscAbility" as const, 895 | }; 896 | }); 897 | 898 | const specialRuleItems = (data.special || []).map((sr): FUItem => { 899 | return { 900 | name: sr.name != "" ? sr.name : "Unnamed Special Rule", 901 | system: { 902 | description: sr.effect, 903 | isBehavior: false, 904 | weight: { value: 1 }, 905 | hasClock: { value: false }, 906 | }, 907 | type: "rule" as const, 908 | }; 909 | }); 910 | 911 | const rareGearItems = (data.raregear || []).map((sr): FUItem => { 912 | return { 913 | name: sr.name != "" ? sr.name : "Unnamed Rare Gear", 914 | system: { 915 | description: sr.effect, 916 | isBehavior: false, 917 | weight: { value: 1 }, 918 | hasClock: { value: false }, 919 | }, 920 | type: "rule" as const, 921 | }; 922 | }); 923 | 924 | const noteItems = (data.notes || []).map((sr): FUItem => { 925 | return { 926 | name: sr.name != "" ? sr.name : "Unnamed Note", 927 | system: { 928 | description: sr.effect, 929 | isBehavior: false, 930 | weight: { value: 1 }, 931 | hasClock: { value: false }, 932 | }, 933 | type: "rule" as const, 934 | }; 935 | }); 936 | 937 | await actor.createEmbeddedDocuments("Item", [ 938 | ...attackItems, 939 | ...weaponAttackItems, 940 | ...spellItems, 941 | ...otherActionItems, 942 | ...specialRuleItems, 943 | ...rareGearItems, 944 | ...noteItems, 945 | ]); 946 | 947 | await actor.createEmbeddedDocuments("Item", equipment); 948 | 949 | await actor.update({ 950 | "system.resources.hp.value": actor.system.resources.hp.max, 951 | "system.resources.mp.value": actor.system.resources.mp.max, 952 | }); 953 | }; 954 | 955 | // Define DataType as an enum 956 | enum DataType { 957 | Npc = "npc", 958 | Pc = "pc", 959 | Class = "class", 960 | PCWeapon = "weapon", 961 | PCArmor = "armor", 962 | PCShield = "shield", 963 | PCAccessory = "accessory", 964 | Arcana = "arcana", 965 | } 966 | 967 | type FultimatorSubmissionData = { 968 | text: string; 969 | preferCompendium?: boolean; 970 | }; 971 | 972 | type FultimatorImportData = FultimatorSubmissionData & { 973 | parse?: Npc | Player | PCWeapon | PCShield | PCArmor | PCAccessory; 974 | error?: string; 975 | inProgress: boolean; 976 | dataType?: DataType; 977 | preferCompendium: boolean; 978 | }; 979 | 980 | export class FultimatorImportApplication extends FormApplication { 981 | async _updateObject(_e: Event, data: T) { 982 | // Save the user's checkbox setting 983 | this.object.preferCompendium = Boolean(data.preferCompendium && data.preferCompendium); 984 | 985 | if (data.text != this.object.text) { 986 | delete this.object.error; 987 | this.object.text = data.text; 988 | 989 | // Determine dataType from text using regex 990 | this.object.dataType = this.detectDataType(data.text); 991 | 992 | try { 993 | switch (this.object.dataType) { 994 | case DataType.Npc: 995 | this.object.parse = json.assertParse(this.object.text); 996 | break; 997 | case DataType.Pc: 998 | this.object.parse = json.assertParse(this.object.text); 999 | break; 1000 | case DataType.PCWeapon: 1001 | this.object.parse = json.assertParse(this.object.text); 1002 | break; 1003 | case DataType.PCShield: 1004 | this.object.parse = json.assertParse(this.object.text); 1005 | break; 1006 | case DataType.PCArmor: 1007 | this.object.parse = json.assertParse(this.object.text); 1008 | break; 1009 | case DataType.PCAccessory: 1010 | this.object.parse = json.assertParse(this.object.text); 1011 | break; 1012 | } 1013 | } catch (e) { 1014 | this.object.error = e instanceof Error ? e.message : String(e); 1015 | } 1016 | } 1017 | this.render(); 1018 | } 1019 | 1020 | detectDataType(text: string): DataType | undefined { 1021 | if (/"dataType"\s*:\s*"npc"/i.test(text)) return DataType.Npc; 1022 | if (/"dataType"\s*:\s*"pc"/i.test(text)) return DataType.Pc; 1023 | if (/"dataType"\s*:\s*"weapon"/i.test(text)) return DataType.PCWeapon; 1024 | if (/"dataType"\s*:\s*"shield"/i.test(text)) return DataType.PCShield; 1025 | if (/"dataType"\s*:\s*"armor"/i.test(text)) return DataType.PCArmor; 1026 | if (/"dataType"\s*:\s*"accessory"/i.test(text)) return DataType.PCAccessory; 1027 | return undefined; 1028 | } 1029 | 1030 | async getData(): Promise { 1031 | const { text, error, inProgress, dataType, preferCompendium } = this.object; 1032 | return { 1033 | ...this.object, 1034 | disabled: !text || !!error || inProgress, 1035 | dataType, 1036 | preferCompendium, 1037 | }; 1038 | } 1039 | 1040 | get template(): string { 1041 | return "modules/fu-parser/templates/import-fultimator.hbs"; 1042 | } 1043 | 1044 | activateListeners(html: JQuery): void { 1045 | super.activateListeners(html); 1046 | html.find("#sub").on("click", async (e) => { 1047 | e.preventDefault(); 1048 | this.object.inProgress = true; 1049 | this.render(); 1050 | try { 1051 | if (this.object.parse) { 1052 | switch (this.object.dataType) { 1053 | case DataType.Npc: 1054 | await importFultimatorNPC(this.object.parse as Npc); 1055 | break; 1056 | case DataType.Pc: 1057 | await importFultimatorPC(this.object.parse as Player, this.object.preferCompendium ?? true); 1058 | break; 1059 | case DataType.PCWeapon: 1060 | await importFultimatorWeapon(this.object.parse as PCWeapon); 1061 | break; 1062 | case DataType.PCShield: 1063 | await importFultimatorShield(this.object.parse as PCShield); 1064 | break; 1065 | case DataType.PCArmor: 1066 | await importFultimatorArmor(this.object.parse as PCArmor); 1067 | break; 1068 | case DataType.PCAccessory: 1069 | await importFultimatorAccessory(this.object.parse as PCAccessory); 1070 | break; 1071 | } 1072 | } 1073 | } finally { 1074 | this.object.inProgress = false; 1075 | this.close(); 1076 | } 1077 | }); 1078 | } 1079 | } 1080 | -------------------------------------------------------------------------------- /src/apps/import-pdf.ts: -------------------------------------------------------------------------------- 1 | import * as pdfjsLib from "pdfjs-dist"; 2 | import { Affinity, Parser, Stat, flatMap, isError, isResult } from "../pdf/parsers/lib"; 3 | import { Consumable, consumablesPage } from "../pdf/parsers/consumablePage"; 4 | import { Weapon, basicWeapons, rareWeapons } from "../pdf/parsers/weaponPage"; 5 | import { Armor, armorPage } from "../pdf/parsers/armorPage"; 6 | import { Shield, shieldPage } from "../pdf/parsers/shieldPage"; 7 | import { Accessory, accessories } from "../pdf/parsers/accessoryPage"; 8 | import { Beast, beastiary } from "../pdf/parsers/beastiaryPage"; 9 | import { StringToken } from "../pdf/lexers/token"; 10 | import { tokenizePDF } from "../pdf/lexers/pdf"; 11 | import { ATTR, FUActor, FUItem, getFolder, saveImage } from "../external/project-fu"; 12 | 13 | // Relative url that foundry serves for the compiled webworker 14 | pdfjsLib.GlobalWorkerOptions.workerSrc = "modules/fu-parser/pdf.worker.js"; 15 | 16 | // Foundry v10 creates these methods, but pdfjs does not like extra methods on Object that are enumerable, 17 | // so fix the compatibility issue 18 | for (const prop of ["deepFlatten", "equals", "partition", "filterJoin", "findSplice"]) { 19 | Object.defineProperty(Array.prototype, prop, { 20 | enumerable: false, 21 | }); 22 | } 23 | 24 | type Wrapper = ( 25 | p: Parser, 26 | s: (t: T[], pn: number, f: readonly string[], imagePath: string) => Promise, 27 | ) => Promise; 28 | 29 | const AFF_MAPPING: Record = { 30 | VU: -1, 31 | N: 0, 32 | RS: 1, 33 | IM: 2, 34 | AB: 3, 35 | }; 36 | 37 | const STAT_MAPPING: Record = { 38 | DEX: "dex", 39 | MIG: "mig", 40 | INS: "ins", 41 | WLP: "wlp", 42 | }; 43 | const saveConsumables = async ( 44 | categories: [string, Consumable[]][], 45 | pageNum: number, 46 | folderNames: readonly string[], 47 | imagePath: string, 48 | ) => { 49 | for (const [category, consumables] of categories) { 50 | const folder = await getFolder([...folderNames, category], "Item"); 51 | if (folder) { 52 | for (const data of consumables) { 53 | await saveImage(data.image, data.name + ".png", imagePath); 54 | const payload: FUItem = { 55 | type: "consumable" as const, 56 | name: data.name, 57 | img: imagePath + "/" + data.name + ".png", 58 | folder: folder._id, 59 | system: { 60 | ipCost: { value: data.ipCost }, 61 | description: data.description, 62 | source: { value: pageNum - 2 }, 63 | }, 64 | }; 65 | await Item.create(payload); 66 | } 67 | } 68 | } 69 | }; 70 | 71 | const saveWeapons = async (weapons: Weapon[], pageNum: number, folderNames: readonly string[], imagePath: string) => { 72 | const folder = await getFolder(folderNames, "Item"); 73 | if (folder) { 74 | for (const data of weapons) { 75 | const saved = await saveImage(data.image, data.name + ".png", imagePath); 76 | if (saved && Object.keys(saved).length != 0) { 77 | const payload: FUItem = { 78 | type: "weapon" as const, 79 | name: data.name, 80 | img: imagePath + "/" + data.name + ".png", 81 | folder: folder._id, 82 | system: { 83 | isMartial: { value: data.martial }, 84 | description: data.description === "No Quality." ? "" : data.description, 85 | cost: { value: data.cost }, 86 | attributes: { 87 | primary: { value: STAT_MAPPING[data.accuracy.primary] }, 88 | secondary: { value: STAT_MAPPING[data.accuracy.secondary] }, 89 | }, 90 | accuracy: { value: data.accuracy.bonus }, 91 | damage: { value: data.damage }, 92 | type: { value: data.melee }, 93 | category: { value: data.category }, 94 | hands: { value: data.hands }, 95 | damageType: { value: data.damageType }, 96 | source: { value: pageNum - 2 }, 97 | isBehavior: false, 98 | weight: { value: 1 }, 99 | isCustomWeapon: { value: false }, 100 | }, 101 | }; 102 | await Item.create(payload); 103 | } 104 | } 105 | } 106 | }; 107 | 108 | const saveArmors = async (armors: Armor[], pageNum: number, folderNames: readonly string[], imagePath: string) => { 109 | const folder = await getFolder(folderNames, "Item"); 110 | if (folder) { 111 | for (const data of armors) { 112 | await saveImage(data.image, data.name + ".png", imagePath); 113 | const payload: FUItem = { 114 | type: "armor" as const, 115 | name: data.name, 116 | img: imagePath + "/" + data.name + ".png", 117 | folder: folder._id, 118 | system: { 119 | isMartial: { value: data.martial }, 120 | description: data.description === "No Quality." ? "" : data.description, 121 | cost: { value: data.cost }, 122 | source: { value: pageNum - 2 }, 123 | def: { value: data.def }, 124 | mdef: { value: data.mdef }, 125 | init: { value: data.init }, 126 | isBehavior: false, 127 | weight: { value: 1 }, 128 | }, 129 | }; 130 | await Item.create(payload); 131 | } 132 | } 133 | }; 134 | 135 | const saveAccessories = async ( 136 | accessories: Accessory[], 137 | pageNum: number, 138 | folderNames: readonly string[], 139 | imagePath: string, 140 | ) => { 141 | for (const data of accessories) { 142 | const folder = await getFolder(folderNames, "Item"); 143 | if (folder) { 144 | await saveImage(data.image, data.name + ".png", imagePath); 145 | const payload: FUItem = { 146 | type: "accessory" as const, 147 | name: data.name, 148 | img: imagePath + "/" + data.name + ".png", 149 | folder: folder._id, 150 | system: { 151 | isMartial: { value: false }, 152 | description: data.description, 153 | cost: { value: data.cost }, 154 | source: { value: pageNum - 2 }, 155 | def: { value: 0 }, 156 | mdef: { value: 0 }, 157 | init: { value: 0 }, 158 | isBehavior: false, 159 | weight: { value: 1 }, 160 | }, 161 | }; 162 | await Item.create(payload); 163 | } 164 | } 165 | }; 166 | 167 | const saveShields = async (shields: Shield[], pageNum: number, folderNames: readonly string[], imagePath: string) => { 168 | const folder = await getFolder(folderNames, "Item"); 169 | if (folder) { 170 | for (const data of shields) { 171 | await saveImage(data.image, data.name + ".png", imagePath); 172 | const payload: FUItem = { 173 | type: "shield" as const, 174 | name: data.name, 175 | img: imagePath + "/" + data.name + ".png", 176 | folder: folder._id, 177 | system: { 178 | isMartial: { value: data.martial }, 179 | description: data.description === "No Quality." ? "" : data.description, 180 | cost: { value: data.cost }, 181 | source: { value: pageNum - 2 }, 182 | def: { value: data.def }, 183 | mdef: { value: data.mdef }, 184 | init: { value: data.init }, 185 | isBehavior: false, 186 | weight: { value: 1 }, 187 | }, 188 | }; 189 | await Item.create(payload); 190 | } 191 | } 192 | }; 193 | 194 | const saveBeasts = async (beasts: Beast[], pageNum: number, folderNames: readonly string[], imagePath: string) => { 195 | for (const b of beasts) { 196 | const folder = await getFolder([...folderNames, b.type], "Actor"); 197 | if (folder) { 198 | let mainHandFree = true; 199 | let offHandFree = true; 200 | 201 | const equipment = (b.equipment || []) 202 | .map((e) => { 203 | const item = game.items.find((f) => f.name.toLowerCase() === e.toLowerCase()) as FUItem; 204 | if (item) { 205 | const data = duplicate(item); 206 | const itemType = data.type; 207 | if (itemType === "weapon") { 208 | if (mainHandFree) { 209 | data.system.isEquipped = { slot: "mainHand", value: true }; 210 | mainHandFree = false; 211 | if (data.system.hands.value == "two-handed") { 212 | offHandFree = false; 213 | } 214 | } else { 215 | if (offHandFree) { 216 | if (data.system.hands.value == "one-handed") { 217 | data.system.isEquipped = { slot: "offHand", value: true }; 218 | offHandFree = false; 219 | } 220 | } 221 | } 222 | } else if (itemType === "shield") { 223 | if (offHandFree) { 224 | data.system.isEquipped = { slot: "offHand", value: true }; 225 | offHandFree = false; 226 | } 227 | } else if (itemType == "accessory" || itemType == "armor") { 228 | data.system.isEquipped = { slot: itemType, value: true }; 229 | } 230 | return data; 231 | } else { 232 | console.log("Not Found", e); 233 | } 234 | }) 235 | .filter((d): d is FUItem => d !== undefined); 236 | const initBonus = 237 | b.attributes.init - 238 | equipment.reduce( 239 | (acc, i) => 240 | (i.type == "armor" || i.type == "accessory" || i.type == "shield") && i.system.isEquipped 241 | ? i.system.init.value 242 | : 0, 243 | 0, 244 | ) - 245 | (b.attributes.dex + b.attributes.ins) / 2; 246 | const calculatedMaxHp = 2 * b.level + 5 * b.attributes.mig; 247 | 248 | const calculatedMaxMp = b.level + 5 * b.attributes.wlp; 249 | const payload: FUActor = { 250 | system: { 251 | description: b.description, 252 | level: { value: b.level }, 253 | resources: { 254 | hp: { 255 | value: b.attributes.maxHp, 256 | max: calculatedMaxHp, 257 | min: 0, 258 | bonus: b.attributes.maxHp - calculatedMaxHp, 259 | }, 260 | mp: { 261 | value: b.attributes.maxMp, 262 | max: calculatedMaxMp, 263 | min: 0, 264 | bonus: b.attributes.maxMp - calculatedMaxMp, 265 | }, 266 | ip: { value: 6, max: 6, min: 0 }, 267 | fp: { value: 3 }, 268 | }, 269 | affinities: { 270 | physical: { 271 | base: AFF_MAPPING[b.resists.physical], 272 | current: AFF_MAPPING[b.resists.physical], 273 | bonus: 0 as const, 274 | }, 275 | air: { 276 | base: AFF_MAPPING[b.resists.air], 277 | current: AFF_MAPPING[b.resists.air], 278 | bonus: 0 as const, 279 | }, 280 | bolt: { 281 | base: AFF_MAPPING[b.resists.bolt], 282 | current: AFF_MAPPING[b.resists.bolt], 283 | bonus: 0 as const, 284 | }, 285 | dark: { 286 | base: AFF_MAPPING[b.resists.dark], 287 | current: AFF_MAPPING[b.resists.dark], 288 | bonus: 0 as const, 289 | }, 290 | earth: { 291 | base: AFF_MAPPING[b.resists.earth], 292 | current: AFF_MAPPING[b.resists.earth], 293 | bonus: 0 as const, 294 | }, 295 | fire: { 296 | base: AFF_MAPPING[b.resists.fire], 297 | current: AFF_MAPPING[b.resists.fire], 298 | bonus: 0 as const, 299 | }, 300 | ice: { 301 | base: AFF_MAPPING[b.resists.ice], 302 | current: AFF_MAPPING[b.resists.ice], 303 | bonus: 0 as const, 304 | }, 305 | light: { 306 | base: AFF_MAPPING[b.resists.light], 307 | current: AFF_MAPPING[b.resists.light], 308 | bonus: 0 as const, 309 | }, 310 | poison: { 311 | base: AFF_MAPPING[b.resists.poison], 312 | current: AFF_MAPPING[b.resists.poison], 313 | bonus: 0 as const, 314 | }, 315 | }, 316 | attributes: { 317 | dex: { base: b.attributes.dex, current: b.attributes.dex, bonus: 0 as const }, 318 | ins: { base: b.attributes.ins, current: b.attributes.ins, bonus: 0 as const }, 319 | mig: { base: b.attributes.mig, current: b.attributes.mig, bonus: 0 as const }, 320 | wlp: { base: b.attributes.wlp, current: b.attributes.wlp, bonus: 0 as const }, 321 | }, 322 | derived: { 323 | init: { value: b.attributes.init, bonus: initBonus }, 324 | def: { value: 0, bonus: b.equipment == null ? b.attributes.def : 0 }, 325 | mdef: { value: 0, bonus: b.equipment == null ? b.attributes.mdef : 0 }, 326 | accuracy: { value: 0, bonus: 0 }, 327 | magic: { value: 0, bonus: 0 }, 328 | }, 329 | traits: { value: b.traits }, 330 | species: { value: b.type.toLowerCase() }, 331 | useEquipment: { value: b.equipment != null }, 332 | source: { value: pageNum - 2 }, 333 | villain: { value: "" as const }, 334 | isElite: { value: false as const }, 335 | isChampion: { value: 1 as const }, 336 | isCompanion: { value: false as const }, 337 | study: { value: 0 as const }, 338 | }, 339 | type: "npc", 340 | name: b.name, 341 | img: imagePath + "/" + b.name + ".png", 342 | prototypeToken: { texture: { src: imagePath + "/" + b.name + ".png" } }, 343 | folder: folder._id, 344 | }; 345 | await saveImage(b.image, b.name + ".png", imagePath); 346 | const actor = await Actor.create(payload); 347 | 348 | actor.createEmbeddedDocuments("Item", [ 349 | ...b.attacks.map((attack): FUItem => { 350 | return { 351 | type: "basic" as const, 352 | name: attack.name, 353 | system: { 354 | attributes: { 355 | primary: { value: STAT_MAPPING[attack.accuracy.primary] }, 356 | secondary: { value: STAT_MAPPING[attack.accuracy.secondary] }, 357 | }, 358 | accuracy: { value: attack.accuracy.bonus }, 359 | damage: { value: attack.damage }, 360 | type: { value: attack.range }, 361 | damageType: { value: attack.damageType }, 362 | description: attack.description, 363 | isBehavior: false, 364 | weight: { value: 1 }, 365 | quality: { value: "" as const }, 366 | }, 367 | }; 368 | }), 369 | ...b.spells.map((spell): FUItem => { 370 | return { 371 | type: "spell" as const, 372 | name: spell.name, 373 | system: { 374 | mpCost: { value: spell.mp }, 375 | target: { value: spell.target }, 376 | duration: { value: spell.duration }, 377 | isOffensive: { value: spell.accuracy !== null }, 378 | hasRoll: { value: spell.accuracy !== null }, 379 | rollInfo: 380 | spell.accuracy == null 381 | ? undefined 382 | : { 383 | attributes: { 384 | primary: { value: STAT_MAPPING[spell.accuracy.primary] }, 385 | secondary: { value: STAT_MAPPING[spell.accuracy.secondary] }, 386 | }, 387 | accuracy: { value: spell.accuracy.bonus }, 388 | }, 389 | description: spell.description, 390 | isBehavior: false, 391 | weight: { value: 1 }, 392 | quality: { value: spell.opportunity || ("" as const) }, 393 | }, 394 | }; 395 | }), 396 | ...b.otherActions.map((oa): FUItem => { 397 | return { 398 | name: oa.name, 399 | system: { 400 | description: oa.description, 401 | isBehavior: false, 402 | weight: { value: 1 }, 403 | hasClock: { value: false }, 404 | hasRoll: { value: false }, 405 | }, 406 | type: "miscAbility" as const, 407 | }; 408 | }), 409 | ...b.specialRules.map((sr): FUItem => { 410 | return { 411 | name: sr.name, 412 | system: { 413 | description: sr.description, 414 | isBehavior: false, 415 | weight: { value: 1 }, 416 | hasClock: { value: false }, 417 | }, 418 | type: "rule" as const, 419 | }; 420 | }), 421 | ]); 422 | actor.createEmbeddedDocuments("Item", equipment); 423 | } 424 | } 425 | }; 426 | 427 | const PAGES = { 428 | 106: [["Equipment", "Consumables"], (f: Wrapper) => f(consumablesPage, saveConsumables)], 429 | 132: [["Equipment", "Weapons", "Basic"], (f: Wrapper) => f(basicWeapons, saveWeapons)], 430 | 133: [["Equipment", "Weapons", "Basic"], (f: Wrapper) => f(basicWeapons, saveWeapons)], 431 | 134: [["Equipment", "Armors", "Basic"], (f: Wrapper) => f(armorPage, saveArmors)], 432 | 135: [["Equipment", "Shields", "Basic"], (f: Wrapper) => f(shieldPage, saveShields)], 433 | 272: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 434 | 273: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 435 | 274: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 436 | 275: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 437 | 276: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 438 | 277: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 439 | 278: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 440 | 279: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 441 | 280: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 442 | 281: [["Equipment", "Weapons", "Rare"], (f: Wrapper) => f(rareWeapons, saveWeapons)], 443 | 283: [["Equipment", "Armors", "Rare"], (f: Wrapper) => f(armorPage, saveArmors)], 444 | 284: [["Equipment", "Armors", "Rare"], (f: Wrapper) => f(armorPage, saveArmors)], 445 | 285: [["Equipment", "Shields", "Rare"], (f: Wrapper) => f(shieldPage, saveShields)], 446 | 287: [["Equipment", "Accessories"], (f: Wrapper) => f(accessories, saveAccessories)], 447 | 288: [["Equipment", "Accessories"], (f: Wrapper) => f(accessories, saveAccessories)], 448 | 289: [["Equipment", "Accessories"], (f: Wrapper) => f(accessories, saveAccessories)], 449 | 326: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 450 | 327: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 451 | 328: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 452 | 329: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 453 | 330: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 454 | 331: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 455 | 332: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 456 | 333: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 457 | 334: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 458 | 335: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 459 | 336: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 460 | 337: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 461 | 338: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 462 | 339: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 463 | 340: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 464 | 341: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 465 | 342: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 466 | 343: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 467 | 344: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 468 | 345: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 469 | 346: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 470 | 347: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 471 | 348: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 472 | 349: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 473 | 350: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 474 | 351: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 475 | 352: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 476 | 353: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 477 | 354: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 478 | 355: [["Beastiary"], (f: Wrapper) => f(beastiary, saveBeasts)], 479 | } as const; 480 | 481 | type ParseResult = { page: number } & ( 482 | | { type: "success"; save: (imagePath: string) => Promise; cleanup: () => boolean } 483 | | { type: "failure"; errors: { found: string; error: string; distance: number }[] } 484 | | { type: "too many"; count: number; errors: { found: string; error: string; distance: number }[] } 485 | ); 486 | 487 | const pr = (z: string | StringToken) => (typeof z === "string" ? z : ``); 488 | 489 | const parsePdf = async (pdfPath: string): Promise<[ParseResult[], () => Promise]> => { 490 | const [withPage, destroy] = await tokenizePDF(pdfPath); 491 | 492 | return [ 493 | await Promise.all( 494 | Object.entries(PAGES).map(([pageNumStr, [folders, f]]) => { 495 | return f(async (parser, save) => { 496 | const pageNum = Number(pageNumStr); 497 | const [r, cleanup] = await withPage(pageNum, async (data) => { 498 | const parses = parser([data, 0]); 499 | const successes = parses.filter(isResult); 500 | if (successes.length == 1) { 501 | return { 502 | type: "success" as const, 503 | page: pageNum, 504 | results: flatMap<{ name: string } | [string, { name: string }[]], { name: string }>( 505 | successes[0].result[0], 506 | (v) => ("name" in v ? [v] : v[1]), 507 | ), 508 | save: async (imagePath: string) => 509 | await save(successes[0].result[0], pageNum, folders, imagePath), 510 | }; 511 | } else { 512 | const failures = parses.filter(isError); 513 | if (successes.length == 0) { 514 | return { 515 | type: "failure" as const, 516 | page: pageNum, 517 | errors: failures.map((v) => { 518 | return { ...v, found: pr(v.found) }; 519 | }), 520 | }; 521 | } else { 522 | return { 523 | type: "too many" as const, 524 | page: pageNum, 525 | count: successes.length, 526 | errors: failures.map((v) => { 527 | return { ...v, found: pr(v.found) }; 528 | }), 529 | }; 530 | } 531 | } 532 | }); 533 | if (r.type === "success") { 534 | return { ...r, cleanup }; 535 | } else { 536 | cleanup(); 537 | return r; 538 | } 539 | }); 540 | }), 541 | ), 542 | destroy, 543 | ]; 544 | }; 545 | 546 | type ImportPDFSubmissionData = { pdfPath: string; imagePath: string }; 547 | 548 | type ImportPDFData = ImportPDFSubmissionData & { 549 | parseResults: ParseResult[]; 550 | destroy?: () => Promise; 551 | inProgress: boolean; 552 | }; 553 | 554 | export class ImportPDFApplication extends FormApplication { 555 | async _updateObject(_e: Event, data: T) { 556 | if (data.imagePath != this.object.imagePath) { 557 | this.object.imagePath = data.imagePath; 558 | } 559 | if (data.pdfPath != this.object.pdfPath) { 560 | this.cleanupPDFResources(); 561 | this.object.pdfPath = data.pdfPath; 562 | this.render(); 563 | const [results, destroy] = await parsePdf(this.object.pdfPath); 564 | this.object.parseResults = results; 565 | this.object.destroy = destroy; 566 | } 567 | this.render(); 568 | } 569 | 570 | async getData(): Promise { 571 | return { 572 | ...this.object, 573 | disabled: 574 | this.object.imagePath === "" || 575 | this.object.pdfPath === "" || 576 | this.object.parseResults.length == 0 || 577 | this.object.inProgress, 578 | }; 579 | } 580 | get template(): string { 581 | return "modules/fu-parser/templates/import-pdf.hbs"; 582 | } 583 | 584 | async close(options?: unknown) { 585 | this.cleanupPDFResources(); 586 | return super.close(options); 587 | } 588 | 589 | private cleanupPDFResources() { 590 | for (const p of this.object.parseResults) { 591 | if (p.type === "success") { 592 | p.cleanup(); 593 | } 594 | } 595 | if (this.object.destroy) { 596 | this.object.destroy(); 597 | } 598 | this.object.parseResults = []; 599 | delete this.object.destroy; 600 | } 601 | 602 | activateListeners(html: JQuery): void { 603 | super.activateListeners(html); 604 | html.find(".fu-parser-collapsible").on("click", (e) => { 605 | e.preventDefault(); 606 | const toggle = e.currentTarget; 607 | toggle.classList.toggle("fu-parser-active"); 608 | const content = toggle.nextElementSibling as HTMLElement; 609 | if (content?.style.maxHeight) { 610 | content.style.maxHeight = ""; 611 | } else { 612 | content.style.maxHeight = content.scrollHeight + "px"; 613 | } 614 | }); 615 | 616 | html.find("#sub").on("click", async (e) => { 617 | e.preventDefault(); 618 | this.object.inProgress = true; 619 | this.render(); 620 | for (const p of this.object.parseResults) { 621 | if (p.type === "success") { 622 | await p.save(this.object.imagePath); 623 | } 624 | } 625 | this.close(); 626 | }); 627 | } 628 | } 629 | -------------------------------------------------------------------------------- /src/external/fultimator.ts: -------------------------------------------------------------------------------- 1 | export type Attributes = "might" | "dexterity" | "insight" | "will"; 2 | 3 | export type WeaponCategory = 4 | | "Arcane" 5 | | "Bow" 6 | | "Flail" 7 | | "Firearm" 8 | | "Spear" 9 | | "Thrown" 10 | | "Heavy" 11 | | "Dagger" 12 | | "Brawling" 13 | | "Sword"; 14 | 15 | export type Weapon = { 16 | name: string; 17 | cost: number; 18 | category: WeaponCategory; 19 | prec: number; 20 | att1: Attributes; 21 | att2: Attributes; 22 | martial?: boolean; 23 | range?: "melee" | "distance"; 24 | melee?: boolean; 25 | ranged?: boolean; 26 | hands: number; 27 | damage: number; 28 | type: Elements; 29 | quality?: string; 30 | }; 31 | 32 | export type PCWeapon = { 33 | name: string; 34 | category: WeaponCategory; 35 | cost: number; 36 | prec: number; 37 | att1: Attributes; 38 | att2: Attributes; 39 | martial: boolean; 40 | range?: "melee" | "distance"; 41 | melee?: boolean; 42 | ranged?: boolean; 43 | hands: number; 44 | damage: number; 45 | type: Elements; 46 | quality: string; 47 | }; 48 | 49 | export type PCShield = { 50 | name: string; 51 | cost: number; 52 | def: number; 53 | mdef: number; 54 | init: number; 55 | defModifier?: number; 56 | mDefModifier?: number; 57 | initModifier?: number; 58 | martial: boolean; 59 | quality: string; 60 | }; 61 | 62 | export type Affinities = "rs" | "vu" | "ab" | "im" | "no"; 63 | 64 | export type Elements = "physical" | "wind" | "bolt" | "dark" | "earth" | "fire" | "ice" | "light" | "poison"; 65 | 66 | export type Clocks = { 67 | name: string; 68 | sections: number; 69 | }; 70 | 71 | export type NpcAttributes = { 72 | might: number; 73 | insight: number; 74 | will: number; 75 | dexterity: number; 76 | }; 77 | 78 | export type NpcArmor = { 79 | def: number; 80 | name: string; 81 | init: number; 82 | mdefbonus: number; 83 | cost: number; 84 | mdef: number; 85 | defbonus: number; 86 | }; 87 | 88 | export type NpcAttack = { 89 | name: string; 90 | range: "melee" | "distance"; 91 | attr1: Attributes; 92 | attr2: Attributes; 93 | type: Elements; 94 | special: string[]; 95 | extraDamage?: boolean; 96 | flatdmg?: string | number; 97 | flathit?: string | number; 98 | }; 99 | 100 | export type NpcWeaponAttack = { 101 | extraDamage?: boolean; 102 | weapon: Weapon; 103 | name: string; 104 | type?: Elements; 105 | special: string[]; 106 | flathit?: string | number; 107 | flatdmg?: string | number; 108 | }; 109 | 110 | export type NpcSpell = { 111 | effect?: string; 112 | target?: string; 113 | duration?: string; 114 | name: string; 115 | type: string | null; 116 | attr1: Attributes; 117 | attr2: Attributes; 118 | mp?: string; 119 | special: string[]; 120 | }; 121 | 122 | export type NpcAction = { 123 | name: string; 124 | effect: string; 125 | }; 126 | 127 | export type NpcSpecial = { 128 | name: string; 129 | effect: string; 130 | }; 131 | 132 | export type NpcRareGear = { 133 | name: string; 134 | effect: string; 135 | }; 136 | 137 | export type NpcExtra = { 138 | init?: boolean; 139 | precision?: boolean; 140 | hp?: string; 141 | mp?: string; 142 | def?: number; 143 | mDef?: number; 144 | extrainit?: string; 145 | magic?: boolean; 146 | }; 147 | 148 | export type NpcAffinities = { 149 | physical?: Affinities; 150 | wind?: Affinities; 151 | bolt?: Affinities; 152 | dark?: Affinities; 153 | earth?: Affinities; 154 | ice?: Affinities; 155 | light?: Affinities; 156 | poison?: Affinities; 157 | fire?: Affinities; 158 | }; 159 | 160 | export type NpcNotes = { 161 | name: string; 162 | effect: string; 163 | }; 164 | 165 | export type Npc = { 166 | id: string | number; 167 | uid: string; 168 | imgurl?: string; 169 | name: string; 170 | lvl: number; 171 | attacks: NpcAttack[]; 172 | affinities: NpcAffinities; 173 | attributes: NpcAttributes; 174 | species: string; 175 | villain?: "" | "supreme" | "minor" | "major"; 176 | phases?: number | string; 177 | multipart?: string; 178 | rank?: 179 | | "soldier" 180 | | "elite" 181 | | "champion1" 182 | | "champion2" 183 | | "champion3" 184 | | "champion4" 185 | | "champion5" 186 | | "champion6" 187 | | "companion"; 188 | traits?: string; 189 | actions?: NpcAction[]; 190 | extra?: NpcExtra; 191 | spells?: NpcSpell[]; 192 | special?: NpcSpecial[]; 193 | weaponattacks?: NpcWeaponAttack[]; 194 | description?: string; 195 | armor?: NpcArmor; 196 | shield?: NpcArmor; 197 | raregear?: NpcRareGear[]; 198 | label?: string; 199 | notes?: NpcNotes[]; 200 | }; 201 | 202 | type StatValue = { 203 | current: number; 204 | max: number; 205 | }; 206 | 207 | type Stats = { 208 | mp: StatValue; 209 | ip: StatValue; 210 | hp: StatValue; 211 | }; 212 | 213 | export type Player = { 214 | id: string | number; 215 | uid: string; 216 | imgurl?: string; 217 | name: string; 218 | lvl: number; 219 | stats: Stats; 220 | info: PCInfo; 221 | affinities?: PCAffinities; 222 | attributes: PCAttributes; 223 | classes?: PCClasses[]; 224 | weapons?: PCWeaponAttack[]; 225 | armor?: PCArmor[]; 226 | shields?: PCArmor[]; 227 | accessories?: PCAccessory[]; 228 | notes?: PCNotes[]; 229 | modifiers: PCModifiers; 230 | quirk?: PCQuirk; 231 | }; 232 | 233 | export type PCInfo = { 234 | imgurl?: string; 235 | pronouns?: string; 236 | identity?: string; 237 | theme?: string; 238 | origin?: string; 239 | fabulapoints: number; 240 | exp: number; 241 | zenit: number; 242 | description?: string; 243 | bonds: PCBond[]; 244 | }; 245 | 246 | export type PCBond = { 247 | name?: string; 248 | admiration: boolean; 249 | inferiority: boolean; 250 | hatred: boolean; 251 | mistrust: boolean; 252 | affection: boolean; 253 | loyality: boolean; 254 | strength?: number; 255 | }; 256 | 257 | export type PCAttributes = { 258 | might: number; 259 | insight: number; 260 | willpower: number; 261 | dexterity: number; 262 | }; 263 | 264 | export type PCClasses = { 265 | name: string; 266 | lvl: number; 267 | benefits: PCBenefits; 268 | skills: PCSkills[]; 269 | heroic?: PCHeroicSkills; 270 | spells?: PCSpells[]; 271 | }; 272 | 273 | export type PCBenefits = { 274 | hpplus: number; 275 | mpplus: number; 276 | ipplus: number; 277 | martials: PCMartials; 278 | rituals: PCRituals; 279 | spellClasses?: SpellClass[]; 280 | }; 281 | 282 | export type PCMartials = { 283 | shields: boolean; 284 | ranged: boolean; 285 | armor: boolean; 286 | melee: boolean; 287 | }; 288 | export type PCRituals = { 289 | ritualism: boolean; 290 | arcanism?: boolean; 291 | chimerism?: boolean; 292 | elementalism?: boolean; 293 | entropism?: boolean; 294 | spiritism?: boolean; 295 | }; 296 | 297 | type SpellClass = 298 | | "default" 299 | | "arcanist" 300 | | "arcanist-rework" 301 | | "tinkerer-alchemy" 302 | | "tinkerer-infusion" 303 | | "tinkerer-magitech" 304 | | "gamble" 305 | | "magichant" 306 | | "symbol" 307 | | "dance"; 308 | 309 | export type PCSkills = { 310 | skillName: string; 311 | currentLvl: number; 312 | maxLvl: number; 313 | specialSkill?: string; 314 | description: string; 315 | }; 316 | 317 | export interface PCHeroicSkills { 318 | name: string; 319 | description: string; 320 | } 321 | 322 | export type PCSpells = { 323 | name: string; 324 | mp: number; 325 | maxTargets: number; 326 | targetDesc: string; 327 | description: string; 328 | class?: string; 329 | duration: string; 330 | isOffensive: boolean; 331 | attr1: Attributes; 332 | attr2: Attributes; 333 | effect1?: string; 334 | effect2?: string; 335 | effect3?: string; 336 | effect4?: string; 337 | effect5?: string; 338 | effect6?: string; 339 | }; 340 | 341 | export type PCWeaponAttack = { 342 | name: string; 343 | category: WeaponCategory; 344 | melee: boolean; 345 | ranged: boolean; 346 | type: Elements; 347 | hands: number; 348 | att1: Attributes; 349 | att2: Attributes; 350 | martial: boolean; 351 | damageBonus: boolean; 352 | damageReworkBonus: boolean; 353 | precBonus: boolean; 354 | quality: string; 355 | cost: number; 356 | damage: number; 357 | prec: number; 358 | }; 359 | 360 | export type PCArmor = { 361 | name: string; 362 | category: string; 363 | def: number; 364 | mdef: number; 365 | init: number; 366 | defModifier?: number; 367 | mDefModifier?: number; 368 | initModifier?: number; 369 | quality: string; 370 | cost: number; 371 | martial: boolean; 372 | precModifier?: number; 373 | magicModifier?: number; 374 | damageMeleeModifier?: number; 375 | damageRangedModifier?: number; 376 | }; 377 | 378 | export type PCAccessory = { 379 | name: string; 380 | defModifier?: number; 381 | mDefModifier?: number; 382 | initModifier?: number; 383 | quality: string; 384 | cost: number; 385 | precModifier?: number; 386 | magicModifier?: number; 387 | damageMeleeModifier?: number; 388 | damageRangedModifier?: number; 389 | }; 390 | 391 | export type PCSpell = { 392 | effect?: string; 393 | target?: string; 394 | duration?: string; 395 | name: string; 396 | type: string | null; 397 | attr1: Attributes; 398 | attr2: Attributes; 399 | mp?: string; 400 | special: string[]; 401 | }; 402 | 403 | export type PCAffinities = { 404 | physical?: Affinities; 405 | wind?: Affinities; 406 | bolt?: Affinities; 407 | dark?: Affinities; 408 | earth?: Affinities; 409 | ice?: Affinities; 410 | light?: Affinities; 411 | poison?: Affinities; 412 | fire?: Affinities; 413 | }; 414 | 415 | export type PCNotes = { 416 | name: string; 417 | description: string; 418 | clocks?: Clocks[]; 419 | }; 420 | 421 | export type PCModifiers = { 422 | mdef: number; 423 | magicPrec: number; 424 | init: number; 425 | ip: number; 426 | hp: number; 427 | def: number; 428 | meleePrec: number; 429 | rangedPrec: number; 430 | mp: number; 431 | }; 432 | 433 | export type PCQuirk = { 434 | effect: string; 435 | name: string; 436 | description: string; 437 | }; 438 | -------------------------------------------------------------------------------- /src/external/project-fu.ts: -------------------------------------------------------------------------------- 1 | import { Image } from "../pdf/lexers/token"; 2 | 3 | declare global { 4 | const game: { 5 | packs: any; 6 | folders: Collection; 7 | user: { isGM: boolean }; 8 | items: Collection; 9 | }; 10 | const Hooks: { 11 | on(s: "renderSettings", f: (app: unknown, html: JQuery) => unknown): null; 12 | }; 13 | const duplicate: (d: T) => T; 14 | const Folder: { create(payload: { name: string; type: string; folder?: string }): Promise }; 15 | const Item: { create(payload: T): Promise }; 16 | const Actor: { create(payload: T): Promise }; 17 | const FilePicker: { 18 | /** 19 | * Dispatch a POST request to the server containing a directory path and a file to upload 20 | * @param {string} source The data source to which the file should be uploaded 21 | * @param {string} path The destination path 22 | * @param {File} file The File object to upload 23 | * @param {object} [body={}] Additional file upload options sent in the POST body 24 | * @param {object} [options] Additional options to configure how the method behaves 25 | * @param {boolean} [options.notify=true] Display a UI notification when the upload is processed 26 | * @returns {Promise} The response object 27 | */ 28 | upload( 29 | source: string, 30 | path: string, 31 | file: File, 32 | body?: { [key: string]: string | Blob }, 33 | options?: { notify?: boolean }, 34 | ): Promise>; 35 | }; 36 | class FormApplication { 37 | constructor(object?: T, options?: unknown); 38 | render(force?: boolean): FormApplication; 39 | activateListeners(html: JQuery): void; 40 | object: T; 41 | close(options?: unknown): Promise; 42 | } 43 | } 44 | 45 | type Folder = { 46 | _id: string; 47 | name: string; 48 | type: string; 49 | getSubfolders(): Collection; 50 | }; 51 | 52 | type Document = { 53 | createEmbeddedDocuments(type: "Item", data: T[]): Promise; 54 | update(data: unknown): Promise; 55 | }; 56 | 57 | type Item = { 58 | name: string; 59 | img?: string; 60 | folder?: string; 61 | type: string; 62 | }; 63 | 64 | type Actor = { 65 | type: string; 66 | name: string; 67 | img?: string; 68 | prototypeToken?: { texture: { src: string } }; 69 | folder?: string; 70 | }; 71 | 72 | type Collection = { 73 | contents: Array; 74 | [Symbol.iterator](): Iterator; 75 | find(f: (q: Q) => boolean): Q | null; 76 | }; 77 | 78 | export const getFolder = async (folders: readonly string[], type: string) => { 79 | let folder: Folder | null = null; 80 | for (const folderName of folders) { 81 | if (folder) { 82 | folder = 83 | folder.getSubfolders().find((f) => f.name === folderName) || 84 | (await Folder.create({ name: folderName, type, folder: folder._id })); 85 | } else { 86 | folder = 87 | game.folders.find((f) => f.name === folderName && f.type == type) || 88 | (await Folder.create({ name: folderName, type })); 89 | } 90 | } 91 | return folder; 92 | }; 93 | 94 | export const saveImage = async ( 95 | img: Image, 96 | name: string, 97 | imagePath: string, 98 | ): Promise | null> => { 99 | try { 100 | const canvas = document.createElement("canvas"); 101 | canvas.width = img.width; 102 | canvas.height = img.height; 103 | const ctx = canvas.getContext("2d"); 104 | if (ctx !== null && "bitmap" in img) { 105 | ctx.drawImage(img.bitmap as ImageBitmap, 0, 0); 106 | 107 | const blob = await new Promise(function (resolve, _reject) { 108 | canvas.toBlob(function (blob) { 109 | resolve(blob); 110 | }); 111 | }); 112 | if (blob) { 113 | return FilePicker.upload("data", imagePath, new File([blob], name), {}, { notify: false }); 114 | } 115 | } 116 | } catch (err) { 117 | console.log(err); 118 | } 119 | return false; 120 | }; 121 | 122 | export type ATTR = "mig" | "wlp" | "dex" | "ins"; 123 | export type CATEGORY = 124 | | "arcane" 125 | | "bow" 126 | | "flail" 127 | | "firearm" 128 | | "spear" 129 | | "thrown" 130 | | "heavy" 131 | | "dagger" 132 | | "brawling" 133 | | "sword"; 134 | 135 | type DamageType = "physical" | "air" | "bolt" | "dark" | "earth" | "fire" | "ice" | "light" | "poison"; 136 | 137 | type Base = { 138 | description: string; 139 | source?: { value: number }; 140 | }; 141 | type SystemItem = { 142 | cost: { value: number }; 143 | }; 144 | type Defensive = { 145 | def: { value: number; attribute?: ATTR }; 146 | mdef: { value: number; attribute?: ATTR }; 147 | init: { value: number }; 148 | }; 149 | 150 | type Equippable = { 151 | isMartial: { value: boolean }; 152 | quality?: { value: string }; 153 | isEquipped?: { value: boolean; slot: string }; 154 | }; 155 | 156 | type Weaponize = { 157 | attributes: { 158 | primary: { value: ATTR }; 159 | secondary: { value: ATTR }; 160 | }; 161 | accuracy: { value: number }; 162 | damage: { value: number }; 163 | type: { value: "melee" | "ranged" }; 164 | category: { value: CATEGORY }; 165 | hands: { value: "one-handed" | "two-handed" }; 166 | damageType: { value: DamageType }; 167 | }; 168 | 169 | type RollInfo = { 170 | hasRoll: { value: boolean }; 171 | rollInfo?: { 172 | useWeapon?: { 173 | accuracy: { value: boolean }; 174 | damage: { value: boolean }; 175 | hrZero: { value: boolean }; 176 | }; 177 | attributes?: { 178 | primary: { value: ATTR }; 179 | secondary: { value: ATTR }; 180 | }; 181 | accuracy?: { value: number }; 182 | damage?: { 183 | hasDamage: { value: boolean }; 184 | value: number; 185 | type: { value: DamageType }; 186 | }; 187 | }; 188 | }; 189 | 190 | type HasBehavior = { 191 | isBehavior: boolean; 192 | weight: { value: number }; 193 | }; 194 | 195 | type HasProgress = { 196 | hasClock: { value: boolean }; 197 | progress?: { current: number; step: number; max: number }; 198 | }; 199 | 200 | type Bonds = { 201 | name: string; 202 | admInf: string; 203 | loyMis: string; 204 | affHat: string; 205 | strength: number; 206 | }; 207 | 208 | export type FUItem = Item & 209 | ( 210 | | { 211 | type: "heroic"; 212 | system: Base & { 213 | subtype: { value: string }; 214 | class: { value: string }; 215 | }; 216 | } 217 | | { 218 | type: "skill"; 219 | system: Base & { 220 | level: { value: number; max: number }; 221 | }; 222 | } 223 | | { 224 | type: "class"; 225 | system: Base & { 226 | level: { value: number }; 227 | benefits: { 228 | resources: { 229 | hp: { value: number }; 230 | mp: { value: number }; 231 | ip: { value: number }; 232 | }; 233 | martials: { 234 | melee: { value: boolean }; 235 | ranged: { value: boolean }; 236 | armor: { value: boolean }; 237 | shields: { value: boolean }; 238 | }; 239 | rituals: { 240 | arcanism: { value?: boolean }; 241 | chimerism: { value?: boolean }; 242 | elementalism: { value?: boolean }; 243 | entropism: { value?: boolean }; 244 | ritualism: { value: boolean }; 245 | spiritism: { value?: boolean }; 246 | }; 247 | }; 248 | }; 249 | } 250 | | { 251 | type: "weapon"; 252 | system: Base & 253 | SystemItem & 254 | Equippable & 255 | Weaponize & 256 | HasBehavior & { 257 | isCustomWeapon: { value: boolean }; 258 | }; 259 | } 260 | | { 261 | type: "armor" | "accessory" | "shield"; 262 | system: Base & SystemItem & Equippable & Defensive & HasBehavior; 263 | } 264 | | { 265 | type: "consumable"; 266 | system: Base & { ipCost: { value: number } }; 267 | } 268 | | { 269 | type: "basic"; 270 | system: Base & 271 | HasBehavior & { 272 | attributes: { 273 | primary: { value: ATTR }; 274 | secondary: { value: ATTR }; 275 | }; 276 | accuracy: { value: number }; 277 | damage: { value: number }; 278 | type: { value: "melee" | "ranged" }; 279 | 280 | damageType: { 281 | value: DamageType | null; 282 | }; 283 | quality: { value: string }; 284 | }; 285 | } 286 | | { 287 | type: "spell"; 288 | system: Base & 289 | RollInfo & 290 | HasBehavior & { 291 | mpCost: { value: string }; 292 | maxTargets?: { value: string }; 293 | target: { value: string }; 294 | duration: { value: string }; 295 | isOffensive: { value: boolean }; 296 | quality: { value: string }; 297 | }; 298 | } 299 | | { 300 | type: "pcSpell"; 301 | system: Base & 302 | RollInfo & 303 | HasBehavior & { 304 | mpCost: { value: string }; 305 | maxTargets?: { value: string }; 306 | target: { value: string }; 307 | duration: { value: string }; 308 | isOffensive: { value: boolean }; 309 | quality: { value: string }; 310 | }; 311 | } 312 | | { 313 | type: "miscAbility"; 314 | system: Base & RollInfo & HasBehavior & HasProgress; 315 | } 316 | | { 317 | type: "rule"; 318 | system: Base & HasBehavior & HasProgress; 319 | } 320 | | { 321 | type: "optionalFeature"; 322 | system: { 323 | optionalType: string; 324 | data: { 325 | description: string; 326 | }; 327 | }; 328 | } 329 | ); 330 | 331 | export type FUActor = Actor & { 332 | type: "npc"; 333 | system: Base & { 334 | level: { value: number }; 335 | resources: { 336 | hp: { value: number; min: number; max: number; bonus: number }; 337 | mp: { value: number; min: number; max: number; bonus: number }; 338 | }; 339 | affinities: { 340 | physical: { base: number; current: number; bonus: 0 }; 341 | air: { base: number; current: number; bonus: 0 }; 342 | bolt: { base: number; current: number; bonus: 0 }; 343 | dark: { base: number; current: number; bonus: 0 }; 344 | earth: { base: number; current: number; bonus: 0 }; 345 | fire: { base: number; current: number; bonus: 0 }; 346 | ice: { base: number; current: number; bonus: 0 }; 347 | light: { base: number; current: number; bonus: 0 }; 348 | poison: { base: number; current: number; bonus: 0 }; 349 | }; 350 | attributes: { 351 | dex: { base: number; current: number; bonus: 0 }; 352 | ins: { base: number; current: number; bonus: 0 }; 353 | mig: { base: number; current: number; bonus: 0 }; 354 | wlp: { base: number; current: number; bonus: 0 }; 355 | }; 356 | derived: { 357 | init: { value: number; bonus: number }; 358 | def: { value: number; bonus: number }; 359 | mdef: { value: number; bonus: number }; 360 | accuracy: { value: number; bonus: number }; 361 | magic: { value: number; bonus: number }; 362 | }; 363 | } & { 364 | resources: { 365 | ip: { value: number; min: number; max: number }; 366 | fp: { value: number }; 367 | }; 368 | traits: { value: string }; 369 | species: { value: string }; 370 | villain: { value: "" | "supreme" | "minor" | "major" }; 371 | phases?: { value: number }; 372 | multipart?: { value: string }; 373 | isElite: { value: boolean }; 374 | isChampion: { value: number }; 375 | isCompanion: { value: boolean }; 376 | useEquipment: { value: boolean }; 377 | study: { value: 0 }; 378 | source?: { value: number }; 379 | }; 380 | }; 381 | 382 | export type FUActorPC = Actor & { 383 | type: "character"; 384 | system: Base & { 385 | level: { value: number }; 386 | resources: { 387 | hp: { value: number; min: number; max: number; bonus: number }; 388 | mp: { value: number; min: number; max: number; bonus: number }; 389 | }; 390 | affinities: { 391 | physical: { base: number; current: number; bonus: 0 }; 392 | air: { base: number; current: number; bonus: 0 }; 393 | bolt: { base: number; current: number; bonus: 0 }; 394 | dark: { base: number; current: number; bonus: 0 }; 395 | earth: { base: number; current: number; bonus: 0 }; 396 | fire: { base: number; current: number; bonus: 0 }; 397 | ice: { base: number; current: number; bonus: 0 }; 398 | light: { base: number; current: number; bonus: 0 }; 399 | poison: { base: number; current: number; bonus: 0 }; 400 | }; 401 | attributes: { 402 | dex: { base: number; current: number; bonus: 0 }; 403 | ins: { base: number; current: number; bonus: 0 }; 404 | mig: { base: number; current: number; bonus: 0 }; 405 | wlp: { base: number; current: number; bonus: 0 }; 406 | }; 407 | derived: { 408 | init: { value: number; bonus: number }; 409 | def: { value: number; bonus: number }; 410 | mdef: { value: number; bonus: number }; 411 | }; 412 | bonuses: { 413 | accuracy: { 414 | accuracyCheck: number; 415 | accuracyMelee: number; 416 | accuracyRanged: number; 417 | magicCheck: number; 418 | }; 419 | damage: { 420 | melee: number; 421 | ranged: number; 422 | spell: number; 423 | }; 424 | }; 425 | } & { 426 | resources: { 427 | ip: { value: number; min: number; max: number; bonus: number }; 428 | fp: { value: number }; 429 | zenit: { value: number }; 430 | bonds: Bonds[]; 431 | exp: { value: number }; 432 | identity: { name: string }; 433 | pronouns: { name: string }; 434 | theme: { name: string }; 435 | origin: { name: string }; 436 | source?: { value: number }; 437 | }; 438 | }; 439 | }; 440 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /**@license 2 | * fu-parser 3 | * 4 | * All rights reserved 5 | * 6 | * ------------------------------------------------------------------------ 7 | * pdf.js 8 | * Copyright 2023 Mozilla Foundation 9 | * 10 | * Licensed under the Apache License, Version 2.0 (the "License"); 11 | * you may not use this file except in compliance with the License. 12 | * You may obtain a copy of the License at 13 | * 14 | * http://www.apache.org/licenses/LICENSE-2.0 15 | * 16 | * Unless required by applicable law or agreed to in writing, software 17 | * distributed under the License is distributed on an "AS IS" BASIS, 18 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | * See the License for the specific language governing permissions and 20 | * limitations under the License. 21 | * 22 | * ------------------------------------------------------------------------ 23 | * typia 24 | * MIT License 25 | * 26 | * Copyright (c) 2022 Jeongho Nam 27 | * 28 | * Permission is hereby granted, free of charge, to any person obtaining a copy 29 | * of this software and associated documentation files (the "Software"), to deal 30 | * in the Software without restriction, including without limitation the rights 31 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 32 | * copies of the Software, and to permit persons to whom the Software is 33 | * furnished to do so, subject to the following conditions: 34 | * 35 | * The above copyright notice and this permission notice shall be included in all 36 | * copies or substantial portions of the Software. 37 | * 38 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 39 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 40 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 41 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 42 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 43 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 44 | * SOFTWARE. 45 | */ 46 | 47 | import { FultimatorImportApplication } from "./apps/import-fultimator"; 48 | import { ImportPDFApplication } from "./apps/import-pdf"; 49 | 50 | Hooks.on("renderSettings", async (_app, $html) => { 51 | if (game.user.isGM) { 52 | const html = $html[0]; 53 | const header = document.createElement("h2"); 54 | header.appendChild(new Text("FU Importer")); 55 | const importPDFButton = document.createElement("button"); 56 | importPDFButton.type = "button"; 57 | importPDFButton.append("Import PDF"); 58 | importPDFButton.addEventListener("click", () => { 59 | const application = new ImportPDFApplication( 60 | { pdfPath: "", imagePath: "", parseResults: [], inProgress: false }, 61 | { 62 | width: 450, 63 | height: 600, 64 | submitOnChange: true, 65 | closeOnSubmit: false, 66 | title: "Fabula Ultima PDF importer", 67 | resizable: true, 68 | }, 69 | ); 70 | application.render(true); 71 | }); 72 | 73 | const importFultimatorButton = document.createElement("button"); 74 | importFultimatorButton.type = "button"; 75 | importFultimatorButton.append("Import Fultimator"); 76 | importFultimatorButton.addEventListener("click", () => { 77 | const application = new FultimatorImportApplication( 78 | { text: "", dataType: undefined, inProgress: false, preferCompendium: true }, 79 | { 80 | width: 450, 81 | height: 600, 82 | submitOnChange: true, 83 | closeOnSubmit: false, 84 | title: "Fultimator import", 85 | resizable: true, 86 | }, 87 | ); 88 | application.render(true); 89 | }); 90 | const div = document.createElement("div"); 91 | div.appendChild(importPDFButton); 92 | div.appendChild(importFultimatorButton); 93 | html.querySelector("#settings-documentation")?.after(header, div); 94 | } 95 | }); 96 | -------------------------------------------------------------------------------- /src/pdf/arbs/arbs.ts: -------------------------------------------------------------------------------- 1 | import fc from "fast-check"; 2 | import { AFFINITIES } from "../parsers/lib"; 3 | 4 | export const word = () => fc.stringMatching(/^\w+$/); 5 | export const multiString = () => fc.array(fc.stringMatching(/^[a-z]+.*$/), { minLength: 1 }); 6 | export const descriptionEnd = () => fc.stringMatching(/^[a-z]+.[.?!]$/); 7 | export const cost = () => fc.nat(); 8 | export const description = () => 9 | fc.tuple(multiString(), descriptionEnd()).map(([descBegin, descEnd]) => [...descBegin, descEnd]); 10 | 11 | export const resistance = () => fc.constantFrom(...AFFINITIES); 12 | -------------------------------------------------------------------------------- /src/pdf/arbs/output.ts: -------------------------------------------------------------------------------- 1 | import { Image, ImageToken, StringToken } from "../lexers/token"; 2 | 3 | export const imageToken = (image: Image): ImageToken => { 4 | return { kind: "Image", image: image }; 5 | }; 6 | export const stringToken = (s: string, f: string = ""): StringToken => { 7 | return { kind: "String", string: s, font: f }; 8 | }; 9 | 10 | export const watermark = stringToken("", "Helvetica"); 11 | -------------------------------------------------------------------------------- /src/pdf/lexers/pdf.ts: -------------------------------------------------------------------------------- 1 | import * as pdfjsLib from "pdfjs-dist"; 2 | import { Image, Token } from "./token"; 3 | import { DocumentInitParameters, TypedArray } from "pdfjs-dist/types/src/display/api"; 4 | 5 | export const tokenizePDF = async ( 6 | docId: string | URL | TypedArray | ArrayBuffer | DocumentInitParameters, 7 | ): Promise< 8 | [(pageNum: number, f: (d: Token[]) => Promise) => Promise<[R, () => boolean]>, () => Promise] 9 | > => { 10 | const doc = await pdfjsLib.getDocument(docId).promise; 11 | 12 | return [ 13 | async (pageNum: number, f: (d: Token[]) => Promise): Promise<[R, () => boolean]> => { 14 | const page = await doc.getPage(pageNum); 15 | 16 | const opList = await page.getOperatorList(); 17 | const data: { font: string; tokens: Token[] } = { font: "", tokens: [] }; 18 | opList.fnArray.map((opCode, index) => { 19 | const args = opList.argsArray[index]; 20 | switch (opCode) { 21 | case pdfjsLib.OPS.paintImageXObject: { 22 | let img: Image | null = null; 23 | try { 24 | img = page.objs.get(args[0]); 25 | } catch (err) { 26 | if (args[0].startsWith("g_")) { 27 | img = page.commonObjs.get(args[0]); 28 | } 29 | } 30 | if (img && img.height > 0 && img.width > 0) { 31 | data.tokens.push({ kind: "Image", image: img }); 32 | } 33 | break; 34 | } 35 | case pdfjsLib.OPS.setFont: { 36 | if (args[0].startsWith("g_")) { 37 | const font = page.commonObjs.get(args[0]); 38 | data.font = font.name; 39 | } 40 | break; 41 | } 42 | case pdfjsLib.OPS.showText: { 43 | if (args.length !== 1) { 44 | throw new Error("Expected text to be an array with a single array element."); 45 | } 46 | const text: string = args[0] 47 | .filter((a: { unicode?: string }) => a.unicode) 48 | .map((a: { unicode: string }) => a.unicode) 49 | .join("") 50 | .trim(); 51 | if (text !== "") { 52 | data.tokens.push({ kind: "String", font: data.font, string: text }); 53 | } 54 | break; 55 | } 56 | } 57 | return null; 58 | }, []); 59 | const r = await f(data.tokens); 60 | return [r, () => page.cleanup()]; 61 | }, 62 | () => doc.destroy(), 63 | ]; 64 | }; 65 | -------------------------------------------------------------------------------- /src/pdf/lexers/token.ts: -------------------------------------------------------------------------------- 1 | export type Image = { 2 | width: number; 3 | height: number; 4 | }; 5 | 6 | export type ImageToken = { kind: "Image"; image: Image }; 7 | export type StringToken = { kind: "String"; string: string; font: string }; 8 | export type Token = ImageToken | StringToken; 9 | 10 | export const isImageToken = (token: Token): token is ImageToken => { 11 | return token.kind === "Image"; 12 | }; 13 | export const isStringToken = (token: Token): token is StringToken => { 14 | return token.kind === "String"; 15 | }; 16 | -------------------------------------------------------------------------------- /src/pdf/parsers/accessoryPage.test.ts: -------------------------------------------------------------------------------- 1 | import fc from "fast-check"; 2 | import { cost, description, word } from "../arbs/arbs"; 3 | import { flatMap, isResult, prettifyStrings } from "./lib"; 4 | import { Image, Token } from "../lexers/token"; 5 | import { imageToken, stringToken, watermark } from "../arbs/output"; 6 | import { Accessory, accessories } from "./accessoryPage"; 7 | 8 | const accessoryDataGen = fc.array( 9 | fc.record({ 10 | name: word(), 11 | cost: cost(), 12 | description: description(), 13 | image: fc.constant({ width: 0, height: 0 } as Image), 14 | }), 15 | { minLength: 1 }, 16 | ); 17 | 18 | test("parses generated", () => { 19 | fc.assert( 20 | fc.property(accessoryDataGen, (data): void => { 21 | const pageTokens: Token[] = [ 22 | imageToken({ width: 0, height: 0 } as Image), 23 | imageToken({ width: 0, height: 0 } as Image), 24 | stringToken(""), 25 | 26 | ...flatMap(data, (m) => [ 27 | imageToken(m.image), 28 | stringToken(m.name), 29 | stringToken(m.cost.toString(), "FBDLWO+PTSans-Narrow"), 30 | ...m.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 31 | ]), 32 | 33 | watermark, 34 | ]; 35 | const parses = accessories([pageTokens, 0]); 36 | const expected: Accessory[] = data.map((v) => { 37 | return { ...v, description: prettifyStrings(v.description) }; 38 | }); 39 | const successful = parses.filter(isResult); 40 | for (const p of successful) { 41 | expect(p.result[0]).toEqual(expected); 42 | } 43 | expect(successful.length).toBe(1); 44 | }), 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /src/pdf/parsers/accessoryPage.ts: -------------------------------------------------------------------------------- 1 | import { Image } from "../lexers/token"; 2 | import { Parser, cost, description, eof, fmap, image, kl, kr, many1, seq, starting, str, then, watermark } from "./lib"; 3 | 4 | export type Accessory = { image: Image; name: string; description: string; cost: number }; 5 | 6 | const accessoryParser: Parser = fmap( 7 | seq(image, str, cost, description), 8 | ([image, name, cost, description]) => { 9 | return { image, name, description, cost }; 10 | }, 11 | ); 12 | export const accessories = kl(kr(starting, many1(accessoryParser)), then(watermark, eof)); 13 | -------------------------------------------------------------------------------- /src/pdf/parsers/armorPage.test.ts: -------------------------------------------------------------------------------- 1 | import fc from "fast-check"; 2 | import { cost, description, word } from "../arbs/arbs"; 3 | import { flatMap, isResult, prettifyStrings } from "./lib"; 4 | import { Image, Token } from "../lexers/token"; 5 | import { imageToken, stringToken, watermark } from "../arbs/output"; 6 | import { Armor, armorPage } from "./armorPage"; 7 | 8 | const armorDataGen = fc.array( 9 | fc.record({ 10 | name: word(), 11 | cost: cost(), 12 | description: description(), 13 | image: fc.constant({ width: 0, height: 0 } as Image), 14 | martial: fc.boolean(), 15 | def: fc.nat(), 16 | mdef: fc.nat(), 17 | init: fc.integer(), 18 | }), 19 | { minLength: 1 }, 20 | ); 21 | 22 | test("parses generated", () => { 23 | fc.assert( 24 | fc.property(armorDataGen, (data): void => { 25 | const pageTokens: Token[] = [ 26 | imageToken({ width: 0, height: 0 } as Image), 27 | imageToken({ width: 0, height: 0 } as Image), 28 | stringToken(""), 29 | 30 | ...flatMap(data, (m) => [ 31 | imageToken(m.image), 32 | stringToken(m.name), 33 | ...(m.martial ? [stringToken("E", "FnT_BasicShapes1")] : []), 34 | stringToken(m.cost.toString(), "FBDLWO+PTSans-Narrow"), 35 | stringToken(`DEX size ${m.def > 0 ? "+" : ""}${m.def}`), 36 | stringToken(`INS size ${m.mdef > 0 ? "+" : ""}${m.mdef}`), 37 | stringToken(`${m.init === 0 ? "-" : m.init}`), 38 | ...m.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 39 | ]), 40 | 41 | watermark, 42 | ]; 43 | const parses = armorPage([pageTokens, 0]); 44 | const expected: Armor[] = data.map((v) => { 45 | return { ...v, description: prettifyStrings(v.description) }; 46 | }); 47 | const successful = parses.filter(isResult); 48 | for (const p of successful) { 49 | expect(p.result[0]).toEqual(expected); 50 | } 51 | expect(successful.length).toBe(1); 52 | }), 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /src/pdf/parsers/armorPage.ts: -------------------------------------------------------------------------------- 1 | import { Image } from "../lexers/token"; 2 | import { 3 | Parser, 4 | alt, 5 | cost, 6 | dashOrNumber, 7 | descriptionEnd, 8 | eof, 9 | fmap, 10 | image, 11 | kl, 12 | kr, 13 | many, 14 | many1, 15 | martial, 16 | prettifyStrings, 17 | seq, 18 | starting, 19 | str, 20 | strWithFont, 21 | success, 22 | then, 23 | watermark, 24 | } from "./lib"; 25 | 26 | export type Armor = { 27 | image: Image; 28 | name: string; 29 | martial: boolean; 30 | cost: number; 31 | def: number; 32 | mdef: number; 33 | init: number; 34 | description: string; 35 | }; 36 | 37 | const convertDef = (prefix: string) => (s: string) => { 38 | if (s.startsWith(prefix + " size")) { 39 | const num = s.slice(10); 40 | return num === "" ? 0 : Number(num); 41 | } else if (s.startsWith(prefix + " die")) { 42 | const num = s.slice(9); 43 | return num === "" ? 0 : Number(num); 44 | } else return s === "-" ? 0 : Number(s); 45 | }; 46 | const def = fmap(str, convertDef("DEX")); 47 | const mdef = fmap(str, convertDef("INS")); 48 | 49 | const init = dashOrNumber("initiative"); 50 | 51 | const armorDescription = fmap( 52 | then( 53 | many(strWithFont([/PTSans-Narrow$/, /PTSans-NarrowBold$/, /Heydings-Icons$/, /KozMinPro-Regular$/])), 54 | descriptionEnd, 55 | ), 56 | ([z, s]) => prettifyStrings([...z, s]), 57 | ); 58 | 59 | const armorParser: Parser = fmap( 60 | seq(image, str, martial, cost, def, mdef, init, armorDescription), 61 | ([image, name, martial, cost, def, mdef, init, description]) => { 62 | return { image, name, martial, cost, def, mdef, init, description }; 63 | }, 64 | ); 65 | 66 | export const armorPage = kl(kr(starting, many1(armorParser)), seq(alt(image, success(null)), watermark, eof)); 67 | -------------------------------------------------------------------------------- /src/pdf/parsers/beastiaryPage.test.ts: -------------------------------------------------------------------------------- 1 | import fc from "fast-check"; 2 | import { description, resistance, descriptionEnd, word } from "../arbs/arbs"; 3 | import { imageToken, stringToken, watermark } from "../arbs/output"; 4 | import { Image, Token } from "../lexers/token"; 5 | import { DAMAGE_TYPES, DIE_SIZES, Distance, STATS, flatMap, isResult, prettifyStrings, TYPE_CODES } from "./lib"; 6 | import { Beast, beastiary } from "./beastiaryPage"; 7 | 8 | const beastiaryDataGen = fc.array( 9 | fc.record({ 10 | image: fc.constant({ width: 0, height: 0 } as Image), 11 | name: word(), 12 | level: fc.nat(), 13 | type: word(), 14 | description: description(), 15 | traits: descriptionEnd(), 16 | attributes: fc.record({ 17 | dex: fc.constantFrom(...DIE_SIZES), 18 | ins: fc.constantFrom(...DIE_SIZES), 19 | mig: fc.constantFrom(...DIE_SIZES), 20 | wlp: fc.constantFrom(...DIE_SIZES), 21 | maxHp: fc.integer({ min: 5, max: 10000 }), 22 | crisis: fc.integer({ min: 5, max: 10000 }), 23 | maxMp: fc.integer({ min: 5, max: 10000 }), 24 | init: fc.integer({ min: 1, max: 100 }), 25 | def: fc.integer({ min: 1, max: 100 }), 26 | mdef: fc.integer({ min: 1, max: 100 }), 27 | }), 28 | resists: fc.record({ 29 | physical: resistance(), 30 | air: resistance(), 31 | bolt: resistance(), 32 | dark: resistance(), 33 | earth: resistance(), 34 | fire: resistance(), 35 | ice: resistance(), 36 | light: resistance(), 37 | poison: resistance(), 38 | }), 39 | equipment: fc.option(fc.array(fc.stringMatching(/^[^,]$/), { minLength: 1 })), 40 | attacks: fc.array( 41 | fc.record({ 42 | range: fc.constantFrom("melee", "ranged"), 43 | name: word(), 44 | accuracy: fc.record({ 45 | primary: fc.constantFrom(...STATS), 46 | secondary: fc.constantFrom(...STATS), 47 | bonus: fc.nat(), 48 | }), 49 | damage: fc.integer({ min: 0, max: 100 }), 50 | damageType: fc.constantFrom(null, ...DAMAGE_TYPES), 51 | description: description(), 52 | }), 53 | { minLength: 1, maxLength: 3 }, 54 | ), 55 | specialRules: fc.array( 56 | fc.record({ 57 | name: fc.string(), 58 | description: description(), 59 | }), 60 | ), 61 | spells: fc.array( 62 | fc.record({ 63 | name: fc.string(), 64 | accuracy: fc.oneof( 65 | fc.constantFrom(null), 66 | fc.record({ 67 | primary: fc.constantFrom(...STATS), 68 | secondary: fc.constantFrom(...STATS), 69 | bonus: fc.nat(), 70 | }), 71 | ), 72 | mp: fc.string(), 73 | target: fc.string(), 74 | duration: fc.string(), 75 | description: description(), 76 | opportunity: fc.option(description()), 77 | }), 78 | ), 79 | otherActions: fc.array( 80 | fc.record({ 81 | name: fc.string(), 82 | description: description(), 83 | }), 84 | ), 85 | }), 86 | { minLength: 1 }, 87 | ); 88 | 89 | test("parses generated", () => { 90 | fc.assert( 91 | fc.property(beastiaryDataGen, (cs): void => { 92 | const pageTokens: Token[] = [ 93 | imageToken({ width: 0, height: 0 } as Image), 94 | imageToken({ width: 0, height: 0 } as Image), 95 | stringToken(""), 96 | ...flatMap(cs, (b) => [ 97 | imageToken(b.image), 98 | stringToken(b.name), 99 | stringToken(`Lv ${b.level}`), 100 | stringToken("w", "XFYKOE+Wingdings-Regular"), 101 | stringToken(b.type), 102 | ...b.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 103 | stringToken("Typical Traits:"), 104 | stringToken(b.traits), 105 | stringToken(`DEX d${b.attributes.dex}`), 106 | stringToken(`INS d${b.attributes.ins}`), 107 | stringToken(`MIG d${b.attributes.mig}`), 108 | stringToken(`WLP d${b.attributes.wlp}`), 109 | stringToken("HP"), 110 | stringToken(b.attributes.maxHp.toString()), 111 | stringToken("w", "XFYKOE+Wingdings-Regular"), 112 | stringToken(b.attributes.crisis.toString()), 113 | stringToken("MP"), 114 | stringToken(b.attributes.maxMp.toString()), 115 | stringToken(`Init. ${b.attributes.init}`), 116 | stringToken(`DEF +${b.attributes.def}`), 117 | stringToken(`M.DEF +${b.attributes.mdef}`), 118 | ...flatMap(DAMAGE_TYPES, (k) => { 119 | const resist = b.resists[k]; 120 | if (resist != null) { 121 | const tok = stringToken(TYPE_CODES[k]); 122 | return [tok, tok, stringToken(resist)]; 123 | } else { 124 | return [stringToken(k)]; 125 | } 126 | }), 127 | ...(b.equipment == null 128 | ? [] 129 | : [stringToken("Equipment:"), stringToken(b.equipment.join(", ") + ".")]), 130 | stringToken("BASIC ATTACKS"), 131 | ...flatMap(b.attacks, (a) => [ 132 | ...(a.range == "melee" 133 | ? [stringToken("$", "DHVFUS+Evilz")] 134 | : [stringToken("a", "QTFAUS+fabulaultima"), stringToken("a", "QTFAUS+fabulaultima")]), 135 | stringToken(a.name), 136 | stringToken("w", "XFYKOE+Wingdings-Regular"), 137 | stringToken("【"), 138 | stringToken(`${a.accuracy.primary} + ${a.accuracy.secondary}`), 139 | stringToken("】"), 140 | ...(a.accuracy.bonus == 0 ? [] : [stringToken(`+${a.accuracy.bonus}`)]), 141 | stringToken("w", "XFYKOE+Wingdings-Regular"), 142 | stringToken("【"), 143 | stringToken(`HR + ${a.damage}`), 144 | stringToken("】"), 145 | ...(a.damageType == null ? [] : [stringToken(a.damageType, "WTLEAG+PTSans-NarrowBold")]), 146 | ...a.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 147 | ]), 148 | ...(b.spells.length == 0 149 | ? [] 150 | : [ 151 | stringToken("SPELLS"), 152 | ...flatMap(b.spells, (spell) => [ 153 | stringToken("h", "DHVFUS+Evilz"), 154 | stringToken(spell.name), 155 | ...(spell.accuracy == null 156 | ? [] 157 | : [ 158 | stringToken("r", "URFDYK+Heydings-Icons"), 159 | stringToken("r", "URFDYK+Heydings-Icons"), 160 | stringToken("w", "XFYKOE+Wingdings-Regular"), 161 | stringToken("【"), 162 | stringToken(`${spell.accuracy.primary} + ${spell.accuracy.secondary}`), 163 | stringToken("】"), 164 | ...(spell.accuracy.bonus == 0 165 | ? [] 166 | : [stringToken(`+${spell.accuracy.bonus}`)]), 167 | ]), 168 | stringToken("w", "XFYKOE+Wingdings-Regular"), 169 | stringToken(spell.mp + " MP"), 170 | stringToken("w", "XFYKOE+Wingdings-Regular"), 171 | stringToken(spell.target), 172 | stringToken("w", "XFYKOE+Wingdings-Regular"), 173 | stringToken(spell.duration), 174 | stringToken("."), 175 | ...spell.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 176 | ...(spell.opportunity == null 177 | ? [] 178 | : [ 179 | stringToken("Opportunity:"), 180 | ...spell.opportunity.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 181 | ]), 182 | ]), 183 | ]), 184 | ...(b.otherActions.length == 0 185 | ? [] 186 | : [ 187 | stringToken("OTHER ACTIONS"), 188 | ...flatMap(b.otherActions, (oa) => [ 189 | stringToken("S", "MNCCQA+WebSymbols-Regular"), 190 | stringToken(oa.name, "WTLEAG+PTSans-NarrowBold"), 191 | stringToken("w", "XFYKOE+Wingdings-Regular"), 192 | ...oa.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 193 | ]), 194 | ]), 195 | ...(b.specialRules.length == 0 196 | ? [] 197 | : [ 198 | stringToken("SPECIAL RULES"), 199 | ...flatMap(b.specialRules, (sr) => [ 200 | stringToken(sr.name), 201 | stringToken("w", "XFYKOE+Wingdings-Regular"), 202 | ...sr.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 203 | ]), 204 | ]), 205 | ]), 206 | watermark, 207 | ]; 208 | const parses = beastiary([pageTokens, 0]); 209 | const expected: Beast[] = cs.map((v) => { 210 | return { 211 | ...v, 212 | description: prettifyStrings(v.description), 213 | attacks: v.attacks.map((a) => { 214 | return { ...a, description: prettifyStrings(a.description) }; 215 | }), 216 | specialRules: v.specialRules.map((a) => { 217 | return { ...a, description: prettifyStrings(a.description) }; 218 | }), 219 | spells: v.spells.map((a) => { 220 | const { opportunity: o, ...rest } = a; 221 | const s: Beast["spells"][number] = { 222 | ...rest, 223 | description: prettifyStrings(a.description), 224 | }; 225 | if (o) { 226 | s.opportunity = prettifyStrings(o); 227 | } 228 | return s; 229 | }), 230 | otherActions: v.otherActions.map((a) => { 231 | return { ...a, description: prettifyStrings(a.description) }; 232 | }), 233 | }; 234 | }); 235 | 236 | const successful = parses.filter(isResult); 237 | for (const p of successful) { 238 | expect(p.result[0]).toEqual(expected); 239 | } 240 | expect(successful.length).toBe(1); 241 | }), 242 | ); 243 | }); 244 | -------------------------------------------------------------------------------- /src/pdf/parsers/beastiaryPage.ts: -------------------------------------------------------------------------------- 1 | import { Image } from "../lexers/token"; 2 | import { 3 | Accuracy, 4 | DamageType, 5 | Parser, 6 | Distance, 7 | ResistanceMap, 8 | Stat, 9 | accuracy, 10 | alt, 11 | damage, 12 | description, 13 | eof, 14 | fmap, 15 | image, 16 | kl, 17 | kr, 18 | many1, 19 | sep, 20 | seq, 21 | starting, 22 | str, 23 | strWithFont, 24 | success, 25 | text, 26 | textWithFont, 27 | then, 28 | matches, 29 | many, 30 | DieSize, 31 | Affinity, 32 | AFFINITIES, 33 | TypeCode, 34 | DIE_SIZES, 35 | DAMAGE_TYPES, 36 | TYPE_CODES, 37 | watermark, 38 | } from "./lib"; 39 | 40 | export type Beast = { 41 | image: Image; 42 | name: string; 43 | level: number; 44 | type: string; 45 | description: string; 46 | traits: string; 47 | attributes: { 48 | dex: DieSize; 49 | ins: DieSize; 50 | mig: DieSize; 51 | wlp: DieSize; 52 | maxHp: number; 53 | crisis: number; 54 | maxMp: number; 55 | init: number; 56 | def: number; 57 | mdef: number; 58 | }; 59 | resists: ResistanceMap; 60 | equipment: string[] | null; 61 | attacks: { 62 | range: Distance; 63 | name: string; 64 | accuracy: Accuracy; 65 | damage: number; 66 | damageType: DamageType | null; 67 | description: string; 68 | }[]; 69 | spells: { 70 | name: string; 71 | accuracy: { 72 | primary: Stat; 73 | secondary: Stat; 74 | bonus: number; 75 | } | null; 76 | mp: string; 77 | target: string; 78 | duration: string; 79 | description: string; 80 | opportunity?: string; 81 | }[]; 82 | otherActions: { 83 | name: string; 84 | description: string; 85 | }[]; 86 | specialRules: { 87 | name: string; 88 | description: string; 89 | }[]; 90 | }; 91 | 92 | const beastAttribute = (stat: Stat) => 93 | fmap(matches(new RegExp(`^${stat} d(${DIE_SIZES.join("|")})`), stat), (t) => 94 | Number(t.slice(stat.length + 2)), 95 | ) as Parser; 96 | const dex = beastAttribute("DEX"); 97 | const ins = beastAttribute("INS"); 98 | const mig = beastAttribute("MIG"); 99 | const wlp = beastAttribute("WLP"); 100 | const beastInit = fmap(matches(/^Init\. [0-9]+$/, "beast init"), (s) => Number(s.slice(6))); 101 | const beastDef = (prefix: string) => 102 | fmap(matches(new RegExp(`^${prefix} \\+?[0-9]+$`), "beast def"), (s) => Number(s.slice(prefix.length + 1))); 103 | const beastAttributes = fmap( 104 | seq( 105 | dex, 106 | ins, 107 | mig, 108 | wlp, 109 | kr( 110 | text("HP"), 111 | fmap(matches(/^[0-9]+$/, "HP"), (s) => Number(s)), 112 | ), 113 | kr( 114 | sep, 115 | fmap(matches(/^[0-9]+$/, "Crisis"), (s) => Number(s)), 116 | ), 117 | kr( 118 | text("MP"), 119 | fmap(matches(/^[0-9]+$/, "MP"), (s) => Number(s)), 120 | ), 121 | beastInit, 122 | beastDef("DEF"), 123 | beastDef("M.DEF"), 124 | ), 125 | ([dex, ins, mig, wlp, maxHp, crisis, maxMp, init, def, mdef]) => { 126 | return { dex, ins, mig, wlp, maxHp, crisis, maxMp, init, def, mdef }; 127 | }, 128 | ); 129 | 130 | const beastResistance = (s: TypeCode): Parser => 131 | alt( 132 | fmap(text(s), () => "N"), 133 | kr(then(text(s), text(s)), matches(new RegExp(AFFINITIES.join("|")), "affinity")) as Parser, 134 | ); 135 | const beastResistances = DAMAGE_TYPES.reduce( 136 | (p, t) => 137 | fmap(then(p, beastResistance(TYPE_CODES[t])), ([m, n]) => { 138 | return { ...m, [t]: n }; 139 | }), 140 | success({}), 141 | ) as Parser; 142 | 143 | const beastAttack = fmap( 144 | seq( 145 | alt( 146 | fmap(textWithFont("$", [/Evilz$/]), () => "melee" as const), 147 | fmap(many1(textWithFont("a", [/fabulaultima$/])), () => "ranged" as const), 148 | ), 149 | str, 150 | kr(sep, accuracy), 151 | kr( 152 | sep, 153 | alt( 154 | then( 155 | damage, 156 | //TODO: Restrict parsed string to actual damagetypes 157 | alt(strWithFont([/PTSans-NarrowBold$/]) as Parser, success(null)), 158 | ), 159 | success([0, null] as const), 160 | ), 161 | ), 162 | description, 163 | ), 164 | ([range, name, accuracy, [damage, damageType], description]) => { 165 | return { range, name, accuracy, damage, damageType, description }; 166 | }, 167 | ); 168 | 169 | const specialRule = fmap(seq(kl(str, sep), description), ([name, description]) => { 170 | return { name, description }; 171 | }); 172 | 173 | const opportunity = kr(text("Opportunity:"), description); 174 | 175 | const beastSpells = kr( 176 | text("SPELLS"), 177 | many1( 178 | fmap( 179 | seq( 180 | kr(textWithFont("h", [/Evilz$/]), str), 181 | alt(kr(many1(textWithFont("r", [/Heydings-Icons$/])), kr(sep, accuracy)), success(null)), 182 | kr( 183 | sep, 184 | fmap(str, (s) => s.slice(0, -3)), 185 | ), 186 | kr(sep, str), 187 | kr(sep, kl(str, text("."))), 188 | description, 189 | alt(opportunity, success(null)), 190 | ), 191 | ([name, accuracy, mp, target, duration, description, opportunity]) => { 192 | if (opportunity) return { name, accuracy, mp, target, duration, description, opportunity }; 193 | else return { name, accuracy, mp, target, duration, description }; 194 | }, 195 | ), 196 | ), 197 | ); 198 | const beastAttacks = kr(text("BASIC ATTACKS"), many1(beastAttack)); 199 | const specialRules = kr(text("SPECIAL RULES"), many1(specialRule)); 200 | const otherActions = kr(text("OTHER ACTIONS"), many1(kr(textWithFont("S", [/WebSymbols-Regular$/]), specialRule))); 201 | const beastParser: Parser = fmap( 202 | seq( 203 | image, 204 | str, 205 | fmap(matches(/^Lv \d+/, "Level"), (s) => Number(s.slice(3))), 206 | kr(sep, str), 207 | description, 208 | kr(text("Typical Traits:"), str), 209 | beastAttributes, 210 | beastResistances, 211 | alt( 212 | kr( 213 | text("Equipment:"), 214 | fmap(str, (s) => s.slice(0, -1).split(", ")), 215 | ), 216 | success(null), 217 | ), 218 | alt(beastAttacks, success([])), 219 | alt(beastSpells, success([])), 220 | alt(otherActions, success([])), 221 | alt(specialRules, success([])), 222 | ), 223 | ([ 224 | image, 225 | name, 226 | level, 227 | type, 228 | description, 229 | traits, 230 | attributes, 231 | resists, 232 | equipment, 233 | attacks, 234 | spells, 235 | otherActions, 236 | specialRules, 237 | ]) => { 238 | return { 239 | image, 240 | name, 241 | level, 242 | type, 243 | description, 244 | traits, 245 | attributes, 246 | resists, 247 | equipment, 248 | attacks, 249 | spells, 250 | otherActions, 251 | specialRules, 252 | }; 253 | }, 254 | ); 255 | export const beastiary = kl( 256 | kr(starting, many1(beastParser)), 257 | seq( 258 | alt( 259 | alt( 260 | alt( 261 | seq( 262 | strWithFont([/MonotypeCorsiva$/]), 263 | strWithFont([/MonotypeCorsiva$/]), 264 | alt(seq(many1(str), strWithFont([/Antonio-Bold$/]), image, many(str), image), success(null)), 265 | ), 266 | seq( 267 | many1(matches(/^.*[^.]$/, "aside")), 268 | matches(/^.*\.$/, "aside"), 269 | strWithFont([/Antonio-Bold$/]), 270 | image, 271 | many(str), 272 | image, 273 | ), 274 | ), 275 | strWithFont([/CreditValley$/]), 276 | ), 277 | success(null), 278 | ), 279 | watermark, 280 | eof, 281 | ), 282 | ); 283 | -------------------------------------------------------------------------------- /src/pdf/parsers/consumablePage.test.ts: -------------------------------------------------------------------------------- 1 | import fc from "fast-check"; 2 | import { cost, multiString, word, description } from "../arbs/arbs"; 3 | import { flatMap, isResult, prettifyStrings } from "./lib"; 4 | import { Image, Token } from "../lexers/token"; 5 | import { imageToken, stringToken, watermark } from "../arbs/output"; 6 | import { Consumable, consumablesPage } from "./consumablePage"; 7 | 8 | const consumableDataGen = fc.array( 9 | fc.tuple( 10 | word(), 11 | fc.array( 12 | fc.record({ 13 | name: multiString(), 14 | ipCost: cost(), 15 | description: description(), 16 | image: fc.constant({ width: 0, height: 0 } as Image), 17 | }), 18 | { minLength: 1 }, 19 | ), 20 | ), 21 | { minLength: 1 }, 22 | ); 23 | 24 | test("parses generated", () => { 25 | fc.assert( 26 | fc.property(consumableDataGen, (cs): void => { 27 | const pageTokens: Token[] = [ 28 | imageToken({ width: 0, height: 0 } as Image), 29 | imageToken({ width: 0, height: 0 } as Image), 30 | stringToken(""), 31 | ...flatMap(cs, ([h, d]) => [ 32 | stringToken(h), 33 | ...flatMap(d, (m) => [ 34 | imageToken(m.image), 35 | ...m.name.map((s) => stringToken(s)), 36 | stringToken(m.ipCost.toString()), 37 | ...m.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 38 | ]), 39 | ]), 40 | stringToken(""), 41 | watermark, 42 | ]; 43 | const parses = consumablesPage([pageTokens, 0]); 44 | const expected: [string, Consumable[]][] = cs.map(([h, vs]) => [ 45 | h, 46 | vs.map((v) => { 47 | return { ...v, description: prettifyStrings(v.description), name: v.name.join(" ") }; 48 | }), 49 | ]); 50 | const successful = parses.filter(isResult); 51 | for (const p of successful) { 52 | expect(p.result[0]).toEqual(expected); 53 | } 54 | expect(successful.length).toBe(1); 55 | }), 56 | ); 57 | }); 58 | -------------------------------------------------------------------------------- /src/pdf/parsers/consumablePage.ts: -------------------------------------------------------------------------------- 1 | import { Image } from "../lexers/token"; 2 | import { 3 | Parser, 4 | description, 5 | eof, 6 | fmap, 7 | image, 8 | kl, 9 | kr, 10 | many1, 11 | matches, 12 | seq, 13 | starting, 14 | str, 15 | then, 16 | watermark, 17 | } from "./lib"; 18 | 19 | export type Consumable = { image: Image; name: string; description: string; ipCost: number }; 20 | 21 | const consumableParser: Parser = fmap( 22 | seq( 23 | image, 24 | fmap(many1(str), (s) => s.join(" ")), 25 | fmap(matches(/^[0-9]+$/, "ipCost"), (s) => Number(s)), 26 | description, 27 | ), 28 | ([image, name, ipCost, description]) => { 29 | return { image, name, ipCost, description }; 30 | }, 31 | ); 32 | 33 | const header = matches(/^[^.?!]*$/, "header"); 34 | export const consumablesPage = kl(kr(starting, many1(then(header, many1(consumableParser)))), seq(str, watermark, eof)); 35 | -------------------------------------------------------------------------------- /src/pdf/parsers/fabula-ultma-pdf.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import fs from "fs"; 3 | import { tokenizePDF } from "../lexers/pdf"; 4 | import { consumablesPage } from "./consumablePage"; 5 | import { Parser, isResult } from "./lib"; 6 | import { basicWeapons, rareWeapons } from "./weaponPage"; 7 | import { armorPage } from "./armorPage"; 8 | import { shieldPage } from "./shieldPage"; 9 | import { accessories } from "./accessoryPage"; 10 | import { beastiary } from "./beastiaryPage"; 11 | 12 | const STANDARD_FONT_DATA_URL = "node_modules/pdfjs-dist/standard_fonts/"; 13 | const FABULA_ULTIMA_PDF_PATH = "data/Fabula_Ultima_-_Core_Rulebook.pdf"; 14 | 15 | const [withPage, destroy] = await tokenizePDF({ 16 | data: new Uint8Array(fs.readFileSync(FABULA_ULTIMA_PDF_PATH)), 17 | standardFontDataUrl: STANDARD_FONT_DATA_URL, 18 | }); 19 | 20 | const pages: [number, string, Parser][] = [ 21 | [106, "Consumables", consumablesPage], 22 | [132, "Weapons - Basic", basicWeapons], 23 | [133, "Weapons - Basic", basicWeapons], 24 | [134, "Armors - Basic", armorPage], 25 | [135, "Shields - Basic", shieldPage], 26 | [272, "Weapons - Rare", rareWeapons], 27 | [273, "Weapons - Rare", rareWeapons], 28 | [274, "Weapons - Rare", rareWeapons], 29 | [275, "Weapons - Rare", rareWeapons], 30 | [276, "Weapons - Rare", rareWeapons], 31 | [277, "Weapons - Rare", rareWeapons], 32 | [278, "Weapons - Rare", rareWeapons], 33 | [279, "Weapons - Rare", rareWeapons], 34 | [280, "Weapons - Rare", rareWeapons], 35 | [281, "Weapons - Rare", rareWeapons], 36 | [283, "Armors - Rare", armorPage], 37 | [284, "Armors - Rare", armorPage], 38 | [285, "Shields - Rare", shieldPage], 39 | [287, "Accessories", accessories], 40 | [288, "Accessories", accessories], 41 | [289, "Accessories", accessories], 42 | [326, "Beastiary", beastiary], 43 | [327, "Beastiary", beastiary], 44 | [328, "Beastiary", beastiary], 45 | [329, "Beastiary", beastiary], 46 | [330, "Beastiary", beastiary], 47 | [331, "Beastiary", beastiary], 48 | [332, "Beastiary", beastiary], 49 | [333, "Beastiary", beastiary], 50 | [334, "Beastiary", beastiary], 51 | [335, "Beastiary", beastiary], 52 | [336, "Beastiary", beastiary], 53 | [337, "Beastiary", beastiary], 54 | [338, "Beastiary", beastiary], 55 | [339, "Beastiary", beastiary], 56 | [340, "Beastiary", beastiary], 57 | [341, "Beastiary", beastiary], 58 | [342, "Beastiary", beastiary], 59 | [343, "Beastiary", beastiary], 60 | [344, "Beastiary", beastiary], 61 | [345, "Beastiary", beastiary], 62 | [346, "Beastiary", beastiary], 63 | [347, "Beastiary", beastiary], 64 | [348, "Beastiary", beastiary], 65 | [349, "Beastiary", beastiary], 66 | [350, "Beastiary", beastiary], 67 | [351, "Beastiary", beastiary], 68 | [352, "Beastiary", beastiary], 69 | [353, "Beastiary", beastiary], 70 | [354, "Beastiary", beastiary], 71 | [355, "Beastiary", beastiary], 72 | ] as const; 73 | 74 | describe("parses pages", () => { 75 | for (const [page, name, f] of pages) { 76 | test(`${page} - ${name}`, async () => { 77 | await withPage(page, async (d) => { 78 | const successful = f([d, 0]).filter(isResult); 79 | expect(successful.length).toBe(1); 80 | }); 81 | }); 82 | } 83 | 84 | // test("current", async () => { 85 | // await withPage(350, async (d) => { 86 | // const data = d.map((t) => (isImageToken(t) ? { ...t, image: { ...t.image, data: [] } } : t)); 87 | // const parses = beastiary([data, 0]); 88 | // // parses.filter(isError).map((z) => console.log(z.error)); 89 | // console.log(data.slice(80)); 90 | 91 | // expect(parses.filter(isResult).length).toBe(1); 92 | // }); 93 | // }); 94 | 95 | afterAll(() => destroy()); 96 | }); 97 | -------------------------------------------------------------------------------- /src/pdf/parsers/lib.ts: -------------------------------------------------------------------------------- 1 | import { ImageToken, StringToken, Token, isImageToken, isStringToken } from "../lexers/token"; 2 | 3 | type PTR = [Token[], number]; 4 | export const nextToken = ([a, i]: PTR): Token | null => a[i]; 5 | export const inc = ([a, i]: PTR): PTR => [a, i + 1]; 6 | export const end = ([a, i]: PTR): boolean => a.length <= i; 7 | 8 | type ErrorPoint = { error: string; distance: number; found: string | StringToken }; 9 | export type Parse = { result: [R, PTR] } | ErrorPoint; 10 | export type Parser = (i: PTR) => Parse[]; 11 | export const result = (r: R, i: PTR): Parse => { 12 | return { result: [r, i] }; 13 | }; 14 | export const isResult = (p: Parse): p is { result: [R, PTR] } => "result" in p; 15 | export const isError = (p: Parse): p is ErrorPoint => "error" in p; 16 | const error = (error: string, distance: number, found: string | StringToken) => { 17 | return { error, distance, found }; 18 | }; 19 | 20 | export const success: (r: R) => Parser = (r) => (i) => [result(r, i)]; 21 | export const fail = 22 | (reason: string): Parser => 23 | (i) => { 24 | const token = nextToken(i); 25 | if (token != null) { 26 | if (isImageToken(token)) { 27 | return [error(reason, i[1], "")]; 28 | } 29 | return [error(reason, i[1], token)]; 30 | } 31 | return [error(reason, i[1], "")]; 32 | }; 33 | export const satisfy = 34 | (p: (r: Token) => boolean, reason: string): Parser => 35 | (ptr) => { 36 | const token = nextToken(ptr); 37 | if (token && p(token)) { 38 | return success(token)(inc(ptr)); 39 | } 40 | return fail(reason)(ptr); 41 | }; 42 | export const flatMap = (arr: readonly T[], fn: (v: T) => R[]) => 43 | arr.reduce((arr, x) => arr.concat(fn(x)), []); 44 | 45 | export const then = 46 | (first: Parser, second: Parser): Parser<[R, S]> => 47 | (i) => { 48 | return flatMap(first(i), (parse) => { 49 | if (isError(parse)) { 50 | return [parse]; 51 | } else { 52 | const [r, remainder] = parse.result; 53 | return flatMap(second(remainder), (z) => { 54 | if (isError(z)) { 55 | return [z]; 56 | } 57 | const [s, secondRemainder] = z.result; 58 | return [result([r, s], secondRemainder)]; 59 | }); 60 | } 61 | }); 62 | }; 63 | export const alt = 64 | (left: Parser, right: Parser): Parser => 65 | (i) => 66 | (left(i) as Parse[]).concat(right(i)); 67 | 68 | export const fmap = 69 | (p: Parser, fn: (r: R) => S): Parser => 70 | (i) => 71 | p(i).map((parse) => { 72 | if (isError(parse)) { 73 | return parse; 74 | } 75 | const [r, remainder] = parse.result; 76 | return result(fn(r), remainder); 77 | }); 78 | 79 | export const eof = (ptr: PTR) => (end(ptr) ? success("eof" as const)(ptr) : fail<"eof">("eof")(ptr)); 80 | 81 | export const many = (p: Parser): Parser => 82 | alt( 83 | fmap( 84 | then(p, (i) => { 85 | const z = many(p)(i); 86 | return z.filter(isResult); 87 | }), 88 | ([r, rs]) => [r].concat(rs), 89 | ), 90 | success([]), 91 | ); 92 | export const many1 = (p: Parser) => fmap(then(p, many(p)), ([r, rs]) => [r].concat(rs)); 93 | 94 | export const image = fmap(satisfy(isImageToken, "typed Image") as Parser, (t) => t.image); 95 | export const str = fmap(satisfy(isStringToken, "typed string") as Parser, (t) => t.string); 96 | export const strWithFont = (fonts: RegExp[]) => 97 | fmap( 98 | satisfy( 99 | (t) => isStringToken(t) && fonts.some((f) => f.test(t.font)), 100 | `string with fonts ${fonts}`, 101 | ) as Parser, 102 | (t) => t.string, 103 | ); 104 | export const text = (s: string) => 105 | satisfy((t) => isStringToken(t) && t.string === s, `text: "${s}"`) as Parser; 106 | export const textWithFont = (s: string, fonts: RegExp[]) => 107 | satisfy( 108 | (t) => isStringToken(t) && t.string === s && fonts.some((f) => f.test(t.font)), 109 | `text: "${s}" with fonts ${fonts}`, 110 | ) as Parser; 111 | 112 | type MapParsers = { [Property in keyof U]: Parser }; 113 | 114 | export const seq = (p: Parser, ...rst: MapParsers): Parser<[T, ...U]> => { 115 | if (rst.length === 0) { 116 | return fmap(p, (t) => [t] as unknown as [T, ...U]); 117 | } 118 | return fmap( 119 | then(p, seq(rst[0], ...rst.slice(1))), 120 | ([r, s]: [unknown, unknown[]]) => [r].concat(s) as unknown as [T, ...U], 121 | ); 122 | }; 123 | 124 | export const kl = (l: Parser, r: Parser): Parser => fmap(then(l, r), ([o, _v]) => o); 125 | export const kr = (l: Parser, r: Parser): Parser => fmap(then(l, r), ([_o, v]) => v); 126 | 127 | /** Fabula Ultima pdf specific useful parsers */ 128 | 129 | export const descriptionEnd = fmap( 130 | satisfy((t) => isStringToken(t) && /.*[.?!]$/.test(t.string), "description end") as Parser, 131 | (t) => t.string, 132 | ); 133 | 134 | export const descriptionLine = fmap( 135 | satisfy( 136 | (t) => 137 | isStringToken(t) && 138 | [/PTSans-Narrow$/, /PTSans-NarrowBold$/, /Heydings-Icons$/, /KozMinPro-Regular$/].some((r) => 139 | r.test(t.font), 140 | ) && 141 | !/^Opportunity:/.test(t.string), 142 | "description line", 143 | ) as Parser, 144 | (t) => t.string, 145 | ); 146 | export const description = alt( 147 | fmap(seq(strWithFont([/PTSans-Narrow$/]), many(descriptionLine), descriptionEnd), ([h, z, s]) => 148 | prettifyStrings([h, ...z, s]), 149 | ), 150 | descriptionEnd, 151 | ); 152 | 153 | export const starting = seq(image, image, many1(str)); 154 | 155 | export const sep = textWithFont("w", [/Wingdings-Regular$/]); 156 | export const matches = (r: RegExp, errorMsg: string) => 157 | fmap(satisfy((t) => isStringToken(t) && r.test(t.string), errorMsg) as Parser, (t) => t.string); 158 | 159 | export type Distance = (typeof DISTANCES)[number]; 160 | export const DISTANCES = ["melee", "ranged"] as const; 161 | export type Handed = (typeof HANDED)[number]; 162 | export const HANDED = ["one-handed", "two-handed"] as const; 163 | export type WeaponCategory = (typeof WEAPON_CATEGORIES)[number]; 164 | export const WEAPON_CATEGORIES = [ 165 | "arcane", 166 | "bow", 167 | "brawling", 168 | "dagger", 169 | "firearm", 170 | "flail", 171 | "heavy", 172 | "spear", 173 | "sword", 174 | "thrown", 175 | ] as const; 176 | 177 | export type Stat = (typeof STATS)[number]; 178 | export const STATS = ["DEX", "MIG", "INS", "WLP"] as const; 179 | const isStat = (s: string): s is Stat => { 180 | return (STATS as readonly string[]).includes(s); 181 | }; 182 | 183 | export type TypeCode = (typeof TYPE_CODES)[keyof typeof TYPE_CODES]; 184 | export const TYPE_CODES = { 185 | physical: "'", 186 | air: "a", 187 | bolt: "b", 188 | dark: "a", 189 | earth: "E", 190 | fire: "f", 191 | ice: "i", 192 | light: "l", 193 | poison: "b", 194 | } as const; 195 | 196 | export type DamageType = (typeof DAMAGE_TYPES)[number]; 197 | export const DAMAGE_TYPES = ["physical", "air", "bolt", "dark", "earth", "fire", "ice", "light", "poison"] as const; 198 | 199 | export type Affinity = (typeof AFFINITIES)[number]; 200 | export const AFFINITIES = ["VU", "N", "RS", "IM", "AB"] as const; 201 | 202 | export type ResistanceMap = Record; 203 | export type Accuracy = { primary: Stat; secondary: Stat; bonus: number }; 204 | 205 | const bonus = alt( 206 | fmap(satisfy((t) => isStringToken(t) && /\+\d*/.test(t.string), "Accuracy bonus") as Parser, (t) => 207 | Number(t.string.slice(1)), 208 | ), 209 | success(0), 210 | ); 211 | 212 | const statsForAccuracy: Parser<[Stat, Stat]> = (ptr: PTR) => { 213 | const token = nextToken(ptr); 214 | if (token && isStringToken(token) && token.string.length == 9) { 215 | const primary = token.string.slice(0, 3); 216 | const secondary = token.string.slice(-3); 217 | if (isStat(primary) && isStat(secondary)) { 218 | return success<[Stat, Stat]>([primary, secondary])(inc(ptr)); 219 | } 220 | } 221 | return fail<[Stat, Stat]>("Accuracy attributes")(ptr); 222 | }; 223 | export const accuracy = fmap( 224 | then(kl(kr(text("【"), statsForAccuracy), text("】")), bonus), 225 | ([[primary, secondary], bonus]) => { 226 | return { primary, secondary, bonus }; 227 | }, 228 | ); 229 | 230 | const statsForDamage: Parser = (ptr: PTR) => { 231 | const token = nextToken(ptr); 232 | if (token && isStringToken(token) && /HR \+ \d+/.test(token.string)) { 233 | return success(Number(token.string.slice(5)))(inc(ptr)); 234 | } 235 | return fail("Damage")(ptr); 236 | }; 237 | export const damage = kl(kr(text("【"), statsForDamage), text("】")); 238 | 239 | //TODO: Parse damage type instead of just any string 240 | export const damageType: Parser = fmap(many1(str), (ts) => ts.join("") as DamageType); 241 | export const hands: Parser = alt( 242 | fmap(text("One-handed"), () => "one-handed"), 243 | fmap(text("Two-handed"), () => "two-handed"), 244 | ); 245 | export const melee: Parser = alt( 246 | fmap(text("Melee"), () => "melee"), 247 | fmap(text("Ranged"), () => "ranged"), 248 | ); 249 | 250 | export const martial = alt( 251 | fmap(textWithFont("E", [/FnT_BasicShapes1$/]), (_e) => true), 252 | success(false), 253 | ); 254 | const convertCosts = (s: string) => { 255 | if (s.endsWith(" z")) { 256 | return Number(s.slice(0, -2)); 257 | } else if (s === "-") { 258 | return 0; 259 | } else { 260 | return Number(s); 261 | } 262 | }; 263 | export const cost = fmap(strWithFont([/PTSans-Narrow$/]), convertCosts); 264 | 265 | export const dashOrNumber = (errorMsg: string) => 266 | fmap(matches(/^((\+|-)?[0-9]+)|-$/, errorMsg), (s: string) => (s === "-" ? 0 : Number(s))); 267 | 268 | export type DieSize = (typeof DIE_SIZES)[number]; 269 | export const DIE_SIZES = [6, 8, 10, 12] as const; 270 | 271 | export const prettifyStrings = (lines: string[]): string => { 272 | return lines.reduce((acc, line) => { 273 | const s = line.trim(); 274 | if (/^[.?!]/.test(s)) { 275 | return acc + s; 276 | } else { 277 | return acc + " " + s; 278 | } 279 | }, ""); 280 | }; 281 | 282 | export const watermark = strWithFont([/Helvetica$/]); 283 | -------------------------------------------------------------------------------- /src/pdf/parsers/sheildPage.test.ts: -------------------------------------------------------------------------------- 1 | import fc from "fast-check"; 2 | import { cost, description, word } from "../arbs/arbs"; 3 | import { flatMap, isResult, prettifyStrings } from "./lib"; 4 | import { Image, Token } from "../lexers/token"; 5 | import { imageToken, stringToken, watermark } from "../arbs/output"; 6 | import { Shield, shieldPage } from "./shieldPage"; 7 | 8 | const shieldDataGen = fc.array( 9 | fc.record({ 10 | name: word(), 11 | cost: cost(), 12 | description: description(), 13 | image: fc.constant({ width: 0, height: 0 } as Image), 14 | martial: fc.boolean(), 15 | def: fc.nat(), 16 | mdef: fc.nat(), 17 | init: fc.integer(), 18 | }), 19 | { minLength: 1 }, 20 | ); 21 | 22 | test("parses generated", () => { 23 | fc.assert( 24 | fc.property(shieldDataGen, (data): void => { 25 | const pageTokens: Token[] = [ 26 | imageToken({ width: 0, height: 0 } as Image), 27 | imageToken({ width: 0, height: 0 } as Image), 28 | stringToken(""), 29 | 30 | ...flatMap(data, (m) => [ 31 | imageToken(m.image), 32 | stringToken(m.name), 33 | ...(m.martial ? [stringToken("E", "FnT_BasicShapes1")] : []), 34 | stringToken(m.cost.toString(), "FBDLWO+PTSans-Narrow"), 35 | stringToken(`${m.def === 0 ? "-" : m.def}`), 36 | stringToken(`${m.mdef === 0 ? "-" : m.mdef}`), 37 | stringToken(`${m.init === 0 ? "-" : m.init}`), 38 | ...m.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 39 | ]), 40 | 41 | watermark, 42 | ]; 43 | const parses = shieldPage([pageTokens, 0]); 44 | const expected: Shield[] = data.map((v) => { 45 | return { ...v, description: prettifyStrings(v.description) }; 46 | }); 47 | const successful = parses.filter(isResult); 48 | for (const p of successful) { 49 | expect(p.result[0]).toEqual(expected); 50 | } 51 | expect(successful.length).toBe(1); 52 | }), 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /src/pdf/parsers/shieldPage.ts: -------------------------------------------------------------------------------- 1 | import { Image } from "../lexers/token"; 2 | import { 3 | Parser, 4 | alt, 5 | cost, 6 | dashOrNumber, 7 | description, 8 | eof, 9 | fmap, 10 | image, 11 | kl, 12 | kr, 13 | many1, 14 | martial, 15 | seq, 16 | starting, 17 | str, 18 | success, 19 | watermark, 20 | } from "./lib"; 21 | 22 | export type Shield = { 23 | image: Image; 24 | name: string; 25 | martial: boolean; 26 | cost: number; 27 | def: number; 28 | mdef: number; 29 | init: number; 30 | description: string; 31 | }; 32 | 33 | const def = dashOrNumber("def"); 34 | const mdef = dashOrNumber("mdef"); 35 | const init = dashOrNumber("initiative"); 36 | 37 | const shieldParser: Parser = fmap( 38 | seq(image, str, martial, cost, def, mdef, init, description), 39 | ([image, name, martial, cost, def, mdef, init, description]) => { 40 | return { image, name, martial, cost, def, mdef, init, description }; 41 | }, 42 | ); 43 | 44 | export const shieldPage: Parser = kl( 45 | kr(starting, many1(shieldParser)), 46 | seq(alt(seq(str, str, many1(image)), success(null)), watermark, eof), 47 | ); 48 | -------------------------------------------------------------------------------- /src/pdf/parsers/weaponPage.test.ts: -------------------------------------------------------------------------------- 1 | import fc from "fast-check"; 2 | import { cost, word, description } from "../arbs/arbs"; 3 | import { DAMAGE_TYPES, DISTANCES, HANDED, STATS, WEAPON_CATEGORIES, flatMap, isResult, prettifyStrings } from "./lib"; 4 | import { Image, Token } from "../lexers/token"; 5 | import { imageToken, stringToken, watermark } from "../arbs/output"; 6 | import { Weapon, rareWeapons } from "./weaponPage"; 7 | 8 | const weaponDataGen = fc.record({ 9 | name: word(), 10 | description: description(), 11 | image: fc.constant({ width: 0, height: 0 } as Image), 12 | martial: fc.boolean(), 13 | cost: cost(), 14 | damage: fc.nat(), 15 | accuracy: fc.record({ primary: fc.constantFrom(...STATS), secondary: fc.constantFrom(...STATS), bonus: fc.nat() }), 16 | damageType: fc.constantFrom(...DAMAGE_TYPES), 17 | hands: fc.constantFrom(...HANDED), 18 | melee: fc.constantFrom(...DISTANCES), 19 | }); 20 | 21 | const weaponCategoriesGen = fc.tuple(fc.constantFrom(...WEAPON_CATEGORIES), fc.array(weaponDataGen, { minLength: 1 })); 22 | 23 | test("rare weapon parses generated", () => { 24 | fc.assert( 25 | fc.property(weaponCategoriesGen, ([category, d]): void => { 26 | const pageTokens: Token[] = [ 27 | imageToken({ width: 0, height: 0 } as Image), 28 | imageToken({ width: 0, height: 0 } as Image), 29 | stringToken(""), 30 | stringToken(`SAMPLE RARE ${category} WEAPONS`), 31 | stringToken(""), 32 | ...flatMap(d, (m) => [ 33 | imageToken(m.image), 34 | stringToken(m.name), 35 | ...(m.martial ? [stringToken("E", "FnT_BasicShapes1")] : []), 36 | stringToken(m.cost.toString(), "FBDLWO+PTSans-Narrow"), 37 | stringToken("【"), 38 | stringToken(`${m.accuracy.primary} + ${m.accuracy.secondary}`), 39 | stringToken("】"), 40 | ...(m.accuracy.bonus > 0 ? [stringToken(`+${m.accuracy.bonus}`)] : []), 41 | stringToken("【"), 42 | stringToken(`HR + ${m.damage}`), 43 | stringToken("】"), 44 | stringToken(m.damageType), 45 | stringToken(m.hands.charAt(0).toUpperCase() + m.hands.slice(1)), 46 | stringToken("w", "Wingdings-Regular"), 47 | stringToken(m.melee.charAt(0).toUpperCase() + m.melee.slice(1)), 48 | stringToken("w", "Wingdings-Regular"), 49 | 50 | ...m.description.map((s) => stringToken(s, "FBDLWO+PTSans-Narrow")), 51 | ]), 52 | watermark, 53 | ]; 54 | const parses = rareWeapons([pageTokens, 0]); 55 | const expected: Weapon[] = d.map((v) => { 56 | return { ...v, description: prettifyStrings(v.description), category }; 57 | }); 58 | const successful = parses.filter(isResult); 59 | for (const p of successful) { 60 | expect(p.result[0]).toEqual(expected); 61 | } 62 | expect(successful.length).toBe(1); 63 | }), 64 | ); 65 | }); 66 | -------------------------------------------------------------------------------- /src/pdf/parsers/weaponPage.ts: -------------------------------------------------------------------------------- 1 | import { Image, Token, isStringToken } from "../lexers/token"; 2 | import { 3 | Accuracy, 4 | DamageType, 5 | Handed, 6 | Parser, 7 | Distance, 8 | accuracy, 9 | alt, 10 | cost, 11 | damage, 12 | damageType, 13 | description, 14 | eof, 15 | fmap, 16 | hands, 17 | image, 18 | kl, 19 | kr, 20 | many1, 21 | martial, 22 | melee, 23 | sep, 24 | seq, 25 | starting, 26 | str, 27 | text, 28 | then, 29 | WeaponCategory, 30 | fail, 31 | nextToken, 32 | success, 33 | inc, 34 | WEAPON_CATEGORIES, 35 | watermark, 36 | } from "./lib"; 37 | 38 | export type Weapon = { 39 | image: Image; 40 | name: string; 41 | martial: boolean; 42 | cost: number; 43 | damage: number; 44 | accuracy: Accuracy; 45 | damageType: DamageType; 46 | hands: Handed; 47 | melee: Distance; 48 | category: WeaponCategory; 49 | description: string; 50 | }; 51 | 52 | const weaponListingParser = fmap( 53 | seq(image, str, martial, cost, accuracy, damage, damageType, kl(hands, sep), kl(melee, sep), description), 54 | ([image, name, martial, cost, accuracy, damage, damageType, hands, melee, description]) => { 55 | return { image, name, martial, cost, damage, accuracy, damageType, hands, melee, description }; 56 | }, 57 | ); 58 | 59 | const advancedStarting: Parser = kl( 60 | kr(seq(image, image, many1(str)), (ptr) => { 61 | const token = nextToken(ptr); 62 | if (token && isStringToken(token) && /^SAMPLE RARE .* WEAPONS$/.test(token.string)) { 63 | return asWeaponCategory(token.string.slice(12, -8).toLowerCase(), ptr); 64 | } 65 | return fail("Category")(ptr); 66 | }), 67 | many1(str), 68 | ); 69 | 70 | function asWeaponCategory(s: string, ptr: [Token[], number]) { 71 | if ((WEAPON_CATEGORIES as readonly string[]).includes(s)) { 72 | return success(s as WeaponCategory)(inc(ptr)); 73 | } 74 | return fail(`Unexpected category ${s}`)(ptr); 75 | } 76 | 77 | const categoryTitle: Parser = (ptr) => { 78 | const token = nextToken(ptr); 79 | if (token && isStringToken(token) && token.string.endsWith(" Category")) { 80 | return asWeaponCategory(token.string.slice(0, -9).toLowerCase(), ptr); 81 | } 82 | return fail("Category")(ptr); 83 | }; 84 | const weaponsParser = fmap(then(categoryTitle, many1(weaponListingParser)), ([category, weapons]) => 85 | weapons.map((v) => { 86 | return { ...v, category }; 87 | }), 88 | ); 89 | const ending = then(alt(then(text("BASIC WEAPONS"), watermark), watermark), eof); 90 | 91 | export const basicWeapons: Parser = fmap(kl(kr(starting, many1(weaponsParser)), ending), (k) => k.flat(1)); 92 | export const rareWeapons: Parser = kl( 93 | fmap(then(advancedStarting, many1(weaponListingParser)), ([category, weapons]) => 94 | weapons.map((v) => { 95 | return { ...v, category }; 96 | }), 97 | ), 98 | then(watermark, eof), 99 | ); 100 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | /* Style the button that is used to open and close the collapsible content */ 2 | .fu-parser-parse-success { 3 | color: #3a3a3a; 4 | background-color: #b3edc2; 5 | padding: 5px; 6 | width: 100%; 7 | border: none; 8 | text-align: left; 9 | outline: none; 10 | user-select: text; 11 | cursor: pointer; 12 | } 13 | 14 | .fu-parser-parse-failure { 15 | color: #3a3a3a; 16 | background-color: #e88686; 17 | padding: 5px; 18 | width: 100%; 19 | border: none; 20 | text-align: left; 21 | outline: none; 22 | user-select: text; 23 | cursor: pointer; 24 | } 25 | 26 | /* Add a background color to the button if it is clicked on (add the .active class with JS), and when you move the mouse over it (hover) */ 27 | .fu-parser-active.fu-parser-parse-success, 28 | .fu-parser-parse-failure:hover { 29 | background-color: #7faa8a; 30 | } 31 | .fu-parser-active.fu-parser-parse-failure, 32 | .fu-parser-parse-failure:hover { 33 | background-color: #c15454; 34 | } 35 | 36 | /* Style the collapsible content. Note: hidden by default */ 37 | .fu-parser-content { 38 | padding: 0 18px; 39 | background-color: white; 40 | max-height: 0; 41 | overflow: hidden; 42 | transition: max-height 0.2s ease-out; 43 | } 44 | 45 | .fu-parser-selectable { 46 | user-select: text; 47 | } 48 | 49 | .fu-parser-collapsible:after { 50 | content: '\02795'; 51 | /* Unicode character for "plus" sign (+) */ 52 | font-size: 13px; 53 | color: white; 54 | float: right; 55 | margin-left: 5px; 56 | } 57 | 58 | .fu-parser-active:after { 59 | content: "\2796"; 60 | /* Unicode character for "minus" sign (-) */ 61 | } 62 | 63 | .fu-parser-pdf-form { 64 | display: flex; 65 | flex-direction: column; 66 | height: 100%; 67 | padding: 10px; 68 | } 69 | 70 | .fu-parser-parse-list { 71 | flex: 1 1; 72 | padding: 10px 0px; 73 | } -------------------------------------------------------------------------------- /src/templates/import-fultimator.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 | {{#if dataType}} 7 | {{dataType}} 8 | {{else}} 9 | No Data 10 | {{/if}} 11 |
12 |

The type of content that will be imported.

13 |
14 | 15 |
16 | 20 |

Use compendium data if available instead of importing entries from json.

21 |
22 | 23 |
24 | 25 |
26 | {{#if error}} 27 |
28 | {{error}} 29 |
30 | {{/if}} 31 |
32 | 33 |
34 |
35 |
-------------------------------------------------------------------------------- /src/templates/import-pdf.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
{{ filePicker type="pdf" target="pdfPath"}}
8 |
9 |
{{ filePicker type="folder" target="imagePath"}}
10 |
11 |
12 |
13 |
14 | 15 |
16 |
17 | {{#if (gt parseResults.length 0)}} 18 |

Preview:

19 | {{#each parseResults}} 20 | 21 | {{#if (eq this.type "success") }} 22 |
23 | Page {{this.page}} - OK 24 |
25 |
26 | {{#each this.results}} 27 |
{{this.name}}
28 | {{/each}} 29 |
30 | {{else}} 31 |
32 | Page {{this.page}} - failure 33 |
34 |
35 | 36 | {{#if (eq this.type "too many") }} 37 |
Successful parses: {{this.count}}
38 | {{/if}} 39 |

Failed parses:

40 |
41 |
Token #
42 |
Looking for
43 |
Found
44 | {{#each this.errors}} 45 |
{{this.distance}}
46 |
{{this.error}}
47 |
{{this.found}}
48 | {{/each}} 49 |
50 |
51 | 52 | {{/if}} 53 | {{/each}} 54 | {{/if}} 55 |
56 |
57 |
-------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // required 4 | "allowSyntheticDefaultImports": true, 5 | "downlevelIteration": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "resolveJsonModule": true, 9 | "experimentalDecorators": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleDetection": "force", 12 | "strict": true, 13 | "target": "ESNext", 14 | // configurable 15 | "rootDir": "src", 16 | "outDir": "out", 17 | "incremental": true, 18 | "tsBuildInfoFile": "out/tsconfig.tsbuildinfo", 19 | "plugins": [ 20 | { 21 | "transform": "typia/lib/transform" 22 | } 23 | ], 24 | "strictNullChecks": true 25 | } 26 | } -------------------------------------------------------------------------------- /webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const TerserPlugin = require("terser-webpack-plugin"); 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | 4 | module.exports = { 5 | mode: "production", 6 | entry: { 7 | main: "./src/main.ts", 8 | "pdf.worker": "pdfjs-dist/build/pdf.worker.mjs", 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.tsx?$/, 14 | use: "ts-loader", 15 | exclude: /node_modules/, 16 | }, 17 | ], 18 | }, 19 | resolve: { 20 | extensions: [".tsx", ".ts", ".js"], 21 | }, 22 | output: { 23 | filename: "[name].js", 24 | }, 25 | optimization: { 26 | minimizer: [ 27 | new TerserPlugin({ 28 | extractComments: /@license/i, 29 | terserOptions: { 30 | keep_classnames: true, 31 | }, 32 | }), 33 | ], 34 | }, 35 | plugins: [ 36 | new CopyPlugin({ 37 | patterns: [ 38 | { from: "module.json", to: "module.json" }, 39 | { from: "src/style.css", to: "style.css" }, 40 | { from: "src/templates", to: "templates" }, 41 | ], 42 | }), 43 | ], 44 | }; 45 | --------------------------------------------------------------------------------