@parseo/appraisals 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +35 -0
  2. package/dist/form-1004mc/extract-checkboxes.d.ts +43 -0
  3. package/dist/form-1004mc/extract-checkboxes.d.ts.map +1 -0
  4. package/dist/form-1004mc/extract-checkboxes.js +145 -0
  5. package/dist/form-1004mc/index.d.ts +3 -0
  6. package/dist/form-1004mc/index.d.ts.map +1 -0
  7. package/dist/form-1004mc/index.js +1 -0
  8. package/dist/form-1004mc/parse-page1.d.ts +8 -0
  9. package/dist/form-1004mc/parse-page1.d.ts.map +1 -0
  10. package/dist/form-1004mc/parse-page1.js +760 -0
  11. package/dist/form-1004mc/parse-sales.d.ts +6 -0
  12. package/dist/form-1004mc/parse-sales.d.ts.map +1 -0
  13. package/dist/form-1004mc/parse-sales.js +505 -0
  14. package/dist/form-1004mc/parser.d.ts +5 -0
  15. package/dist/form-1004mc/parser.d.ts.map +1 -0
  16. package/dist/form-1004mc/parser.js +437 -0
  17. package/dist/form-1004mc/types.d.ts +302 -0
  18. package/dist/form-1004mc/types.d.ts.map +1 -0
  19. package/dist/form-1004mc/types.js +1 -0
  20. package/dist/form-1073/index.d.ts +3 -0
  21. package/dist/form-1073/index.d.ts.map +1 -0
  22. package/dist/form-1073/index.js +1 -0
  23. package/dist/form-1073/parse-page1.d.ts +8 -0
  24. package/dist/form-1073/parse-page1.d.ts.map +1 -0
  25. package/dist/form-1073/parse-page1.js +704 -0
  26. package/dist/form-1073/parse-page2.d.ts +6 -0
  27. package/dist/form-1073/parse-page2.d.ts.map +1 -0
  28. package/dist/form-1073/parse-page2.js +438 -0
  29. package/dist/form-1073/parse-sales.d.ts +7 -0
  30. package/dist/form-1073/parse-sales.d.ts.map +1 -0
  31. package/dist/form-1073/parse-sales.js +477 -0
  32. package/dist/form-1073/parser.d.ts +5 -0
  33. package/dist/form-1073/parser.d.ts.map +1 -0
  34. package/dist/form-1073/parser.js +102 -0
  35. package/dist/form-1073/types.d.ts +300 -0
  36. package/dist/form-1073/types.d.ts.map +1 -0
  37. package/dist/form-1073/types.js +1 -0
  38. package/dist/index.d.ts +13 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +9 -0
  41. package/dist/richer-values/index.d.ts +3 -0
  42. package/dist/richer-values/index.d.ts.map +1 -0
  43. package/dist/richer-values/index.js +1 -0
  44. package/dist/richer-values/parser.d.ts +5 -0
  45. package/dist/richer-values/parser.d.ts.map +1 -0
  46. package/dist/richer-values/parser.js +1067 -0
  47. package/dist/richer-values/types.d.ts +225 -0
  48. package/dist/richer-values/types.d.ts.map +1 -0
  49. package/dist/richer-values/types.js +1 -0
  50. package/package.json +24 -0
@@ -0,0 +1,437 @@
1
+ import { extractLines, UnrecognizedFormatError, toBBox } from "@parseo/shared";
2
+ import { parseSubjectSection, parseContractSection, parseNeighborhoodSection, parseSiteSection, parseImprovementsSection } from "./parse-page1.js";
3
+ import { parseSalesComparisonSection, parseReconciliationSection, parseCostApproachSection } from "./parse-sales.js";
4
+ import { extractCheckedBoxes, resolveCheckbox } from "./extract-checkboxes.js";
5
+ export async function parseForm1004MC(buffer) {
6
+ const lines = await extractLines(buffer);
7
+ return parseForm1004MCFromLines(lines, buffer);
8
+ }
9
+ export async function parseForm1004MCFromLines(lines, buffer, pageOffset = 0) {
10
+ // Find the URAR start page — it may not be page 1 (intro/addendum pages may precede it)
11
+ let urarPage = -1;
12
+ for (const l of lines) {
13
+ if (/Uniform Residential Appraisal Report|Form 1004/i.test(l.fullText)) {
14
+ urarPage = l.page;
15
+ break;
16
+ }
17
+ }
18
+ if (urarPage < 0) {
19
+ throw new UnrecognizedFormatError("Form1004MC", "no page contains a Form 1004 / URAR signature");
20
+ }
21
+ // If the URAR doesn't start on page 1, remap page numbers so section
22
+ // parsers can use page===1, page===2, etc. consistently, and record
23
+ // the offset so checkbox extraction targets the correct physical page.
24
+ if (urarPage > 1) {
25
+ pageOffset += urarPage - 1;
26
+ lines = lines
27
+ .filter((l) => l.page >= urarPage)
28
+ .map((l) => ({ ...l, page: l.page - urarPage + 1 }));
29
+ }
30
+ // ── Page 1: Subject, Contract, Neighborhood, Site, Improvements ──
31
+ const page1 = lines.filter((l) => l.page === 1);
32
+ const subject = parseSubjectSection(page1);
33
+ const contract = parseContractSection(page1);
34
+ const neighborhood = parseNeighborhoodSection(page1);
35
+ const site = parseSiteSection(page1);
36
+ const improvements = parseImprovementsSection(page1);
37
+ // ── Page 2: Sales Comparison (comps 1-3), Reconciliation ──
38
+ const page2 = lines.filter((l) => l.page === 2);
39
+ const salesComparison = parseSalesComparisonSection(page2, 1);
40
+ const reconciliation = parseReconciliationSection(page2);
41
+ // ── Resolve checkbox fields via vector-graphic detection ──
42
+ // In flattened PDFs, all checkbox labels are static text. The checked
43
+ // state is a small X drawn with constructPath ops. We count path shapes
44
+ // at each checkbox position: ≥3 shapes = checked, 1 shape = unchecked.
45
+ // pageOffset adjusts for skipped intro pages so we hit the real page 1 in the buffer.
46
+ const checked = await extractCheckedBoxes(buffer, 1 + pageOffset);
47
+ // Helper: resolve a checkbox and add its bounding box
48
+ function resolveAndBBox(line, field, options, targetBB = neighborhood.boundingBoxes) {
49
+ if (!line)
50
+ return "";
51
+ const value = resolveCheckbox(checked, line.y, options);
52
+ if (value) {
53
+ const seg = line.segments.find((s) => s.text.trim() === value);
54
+ if (seg)
55
+ targetBB[field] = toBBox(seg, line);
56
+ }
57
+ return value;
58
+ }
59
+ const locLine = page1.find((l) => /^Location\b/.test(l.fullText));
60
+ const builtLine = page1.find((l) => /^Built-Up\b/.test(l.fullText));
61
+ const growthLine2 = page1.find((l) => /^Growth\b/.test(l.fullText));
62
+ neighborhood.location = resolveAndBBox(locLine, "location", [
63
+ { x: 75.0, label: "Urban" },
64
+ { x: 122.5, label: "Suburban" },
65
+ { x: 172.0, label: "Rural" },
66
+ ]);
67
+ neighborhood.propertyValues = resolveAndBBox(locLine, "propertyValues", [
68
+ { x: 276.5, label: "Increasing" },
69
+ { x: 334.4, label: "Stable" },
70
+ { x: 383.8, label: "Declining" },
71
+ ]);
72
+ neighborhood.builtUp = resolveAndBBox(builtLine, "builtUp", [
73
+ { x: 75.0, label: "Over 75%" },
74
+ { x: 122.5, label: "25-75%" },
75
+ { x: 172.0, label: "Under 25%" },
76
+ ]);
77
+ neighborhood.demandSupply = resolveAndBBox(builtLine, "demandSupply", [
78
+ { x: 276.5, label: "Shortage" },
79
+ { x: 334.4, label: "In Balance" },
80
+ { x: 383.8, label: "Over Supply" },
81
+ ]);
82
+ neighborhood.growth = resolveAndBBox(growthLine2, "growth", [
83
+ { x: 75.0, label: "Rapid" },
84
+ { x: 122.5, label: "Stable" },
85
+ { x: 172.0, label: "Slow" },
86
+ ]);
87
+ neighborhood.marketingTime = resolveAndBBox(growthLine2, "marketingTime", [
88
+ { x: 276.5, label: "Under 3 mths" },
89
+ { x: 334.4, label: "3-6 mths" },
90
+ { x: 383.8, label: "Over 6 mths" },
91
+ ]);
92
+ // ── Subject checkbox fields ──
93
+ const occLine = page1.find((l) => /^Occupant/i.test(l.fullText));
94
+ subject.occupant = resolveAndBBox(occLine, "occupant", [
95
+ { x: 77.9, label: "Owner" },
96
+ { x: 116.9, label: "Tenant" },
97
+ { x: 157.7, label: "Vacant" },
98
+ ], subject.boundingBoxes);
99
+ // ── Page 3: Cost Approach ──
100
+ const page3 = lines.filter((l) => l.page === 3);
101
+ const costApproach = parseCostApproachSection(page3);
102
+ // ── Page 7 (or addendum page): Additional comparables (comps 4-6) ──
103
+ // Find the addendum page with additional comps by looking for "COMPARABLE SALE # 4" or similar
104
+ const addendumPage = findAdditionalCompsPage(lines);
105
+ if (addendumPage >= 0) {
106
+ const addendumLines = lines.filter((l) => l.page === addendumPage);
107
+ const additionalComps = parseSalesComparisonSection(addendumLines, 4);
108
+ // Merge additional comparables into the main sales comparison
109
+ salesComparison.comparables.push(...additionalComps.comparables);
110
+ if (additionalComps.summaryOfSalesComparison) {
111
+ salesComparison.summaryOfSalesComparison += " " + additionalComps.summaryOfSalesComparison;
112
+ }
113
+ }
114
+ // ── 1004MC Market Conditions Addendum page ──
115
+ const mcPage = findMCPage(lines);
116
+ let marketConditionsAddendum;
117
+ if (mcPage >= 0) {
118
+ const mcLines = lines.filter((l) => l.page === mcPage);
119
+ marketConditionsAddendum = parseMCAddendum(mcLines);
120
+ }
121
+ else {
122
+ // No 1004MC page found — create empty addendum
123
+ marketConditionsAddendum = emptyMCAddendum();
124
+ }
125
+ return {
126
+ subject,
127
+ contract,
128
+ neighborhood,
129
+ site,
130
+ improvements,
131
+ salesComparison,
132
+ reconciliation,
133
+ costApproach,
134
+ marketConditionsAddendum,
135
+ };
136
+ }
137
+ // ── Page detection helpers ────────────────────────────────────────────────
138
+ function findMCPage(lines) {
139
+ for (const line of lines) {
140
+ if (/Market Conditions Addendum/i.test(line.fullText))
141
+ return line.page;
142
+ }
143
+ return -1;
144
+ }
145
+ function findAdditionalCompsPage(lines) {
146
+ for (const line of lines) {
147
+ if (line.page <= 2)
148
+ continue; // Skip the main comp page
149
+ if (/COMPARABLE SALE #\s*[4-9]/i.test(line.fullText))
150
+ return line.page;
151
+ }
152
+ return -1;
153
+ }
154
+ function emptyMCAddendum() {
155
+ const emptyRow = () => ({ prior7to12Months: null, prior4to6Months: null, current3Months: null, overallTrend: null, boundingBoxes: {} });
156
+ return {
157
+ header: { propertyAddress: "", city: "", state: "", zipCode: "", borrower: "", fileNumber: "", boundingBoxes: {} },
158
+ inventoryAnalysis: { totalComparableSales: emptyRow(), absorptionRate: emptyRow(), totalActiveListings: emptyRow(), monthsOfSupply: emptyRow(), boundingBoxes: {} },
159
+ medianSaleListData: { medianSalePrice: emptyRow(), medianSalesDaysOnMarket: emptyRow(), medianListPrice: emptyRow(), medianListingsDaysOnMarket: emptyRow(), medianSalePriceAsPercentOfList: emptyRow(), sellerPaidFinancialAssistance: null, sellerPaidFinancialAssistanceTrend: null, boundingBoxes: {} },
160
+ marketAnalysisText: { sellerConcessionsExplanation: "", foreclosureSalesInMarket: null, foreclosureExplanation: "", dataSources: "", summary: "", boundingBoxes: {} },
161
+ condoCoopProjects: null,
162
+ appraiser: { name: "", companyName: "", companyAddress: "", stateLicenseCertification: "", state: "", email: "", boundingBoxes: {} },
163
+ supervisoryAppraiser: null,
164
+ };
165
+ }
166
+ // ── 1004MC Addendum parsing (existing logic, now returns MarketConditionsAddendum) ──
167
+ function parseMCAddendum(pageLines) {
168
+ const header = parseMCHeader(pageLines);
169
+ const inventoryAnalysis = parseInventoryAnalysis(pageLines);
170
+ const medianSaleListData = parseMedianSaleListData(pageLines);
171
+ const marketAnalysisText = parseMarketAnalysisText(pageLines);
172
+ const condoCoopProjects = parseCondoCoopProjects(pageLines);
173
+ const appraiser = parseMCAppraiserInfo(pageLines, false);
174
+ const supervisoryAppraiser = parseMCAppraiserInfo(pageLines, true);
175
+ return { header, inventoryAnalysis, medianSaleListData, marketAnalysisText, condoCoopProjects, appraiser, supervisoryAppraiser };
176
+ }
177
+ // ── MC Header ─────────────────────────────────────────────────────────────
178
+ function parseMCHeader(lines) {
179
+ const bb = {};
180
+ const addrLine = lines.find((l) => /^Property Address/i.test(l.fullText));
181
+ let propertyAddress = "", city = "", state = "", zipCode = "";
182
+ if (addrLine) {
183
+ for (const seg of addrLine.segments) {
184
+ const text = seg.text.trim();
185
+ if (/^Property Address\s+/i.test(text)) {
186
+ propertyAddress = text.replace(/^Property Address\s+/i, "").trim();
187
+ bb.propertyAddress = toBBox(seg, addrLine);
188
+ }
189
+ else if (/^City\s+/i.test(text)) {
190
+ city = text.replace(/^City\s+/i, "").trim();
191
+ bb.city = toBBox(seg, addrLine);
192
+ }
193
+ else if (/^State\s+/i.test(text)) {
194
+ state = text.replace(/^State\s+/i, "").trim();
195
+ bb.state = toBBox(seg, addrLine);
196
+ }
197
+ else if (/^ZIP Code\s+/i.test(text)) {
198
+ zipCode = text.replace(/^ZIP Code\s+/i, "").trim();
199
+ bb.zipCode = toBBox(seg, addrLine);
200
+ }
201
+ }
202
+ }
203
+ const borrowerLine = lines.find((l) => /^Borrower\s/i.test(l.fullText));
204
+ let borrower = "";
205
+ if (borrowerLine) {
206
+ borrower = borrowerLine.fullText.replace(/^Borrower\s+/i, "").trim();
207
+ if (borrowerLine.segments.length > 0)
208
+ bb.borrower = toBBox(borrowerLine.segments[0], borrowerLine);
209
+ }
210
+ let fileNumber = "";
211
+ for (const line of lines) {
212
+ const fileSeg = line.segments.find((s) => /^File No\.\s/i.test(s.text.trim()) && !/Main File/i.test(s.text));
213
+ if (fileSeg) {
214
+ fileNumber = fileSeg.text.replace(/^File No\.\s*/i, "").trim();
215
+ bb.fileNumber = toBBox(fileSeg, line);
216
+ break;
217
+ }
218
+ }
219
+ return { propertyAddress, city, state, zipCode, borrower, fileNumber, boundingBoxes: bb };
220
+ }
221
+ // ── Grid parsing utilities ────────────────────────────────────────────────
222
+ const COL_PRIOR_7_12 = { min: 195, max: 270 };
223
+ const COL_PRIOR_4_6 = { min: 270, max: 350 };
224
+ const COL_CURRENT_3 = { min: 350, max: 435 };
225
+ function parseNum(raw) {
226
+ if (!raw || /^n\/?a$/i.test(raw.trim()))
227
+ return null;
228
+ const cleaned = raw.replace(/[$,%]/g, "").replace(/,/g, "").trim();
229
+ if (!cleaned)
230
+ return null;
231
+ const n = Number(cleaned);
232
+ return Number.isNaN(n) ? null : n;
233
+ }
234
+ function getValueInRange(segments, min, max) {
235
+ return segments.find((s) => s.x >= min && s.x < max && !/^(Increasing|Stable|Declining)$/i.test(s.text.trim())) ?? null;
236
+ }
237
+ function parseTimePeriodRow(line, bb, prefix) {
238
+ const row = { prior7to12Months: null, prior4to6Months: null, current3Months: null, overallTrend: null, boundingBoxes: {} };
239
+ if (!line)
240
+ return row;
241
+ const segs = line.segments;
242
+ const seg712 = getValueInRange(segs, COL_PRIOR_7_12.min, COL_PRIOR_7_12.max);
243
+ if (seg712) {
244
+ row.prior7to12Months = parseNum(seg712.text);
245
+ row.boundingBoxes[`${prefix}_prior7to12`] = toBBox(seg712, line);
246
+ bb[`${prefix}_prior7to12`] = toBBox(seg712, line);
247
+ }
248
+ const seg46 = getValueInRange(segs, COL_PRIOR_4_6.min, COL_PRIOR_4_6.max);
249
+ if (seg46) {
250
+ row.prior4to6Months = parseNum(seg46.text);
251
+ row.boundingBoxes[`${prefix}_prior4to6`] = toBBox(seg46, line);
252
+ bb[`${prefix}_prior4to6`] = toBBox(seg46, line);
253
+ }
254
+ const segCur = getValueInRange(segs, COL_CURRENT_3.min, COL_CURRENT_3.max);
255
+ if (segCur) {
256
+ row.current3Months = parseNum(segCur.text);
257
+ row.boundingBoxes[`${prefix}_current3`] = toBBox(segCur, line);
258
+ bb[`${prefix}_current3`] = toBBox(segCur, line);
259
+ }
260
+ return row;
261
+ }
262
+ function findRowByLabel(lines, pattern) {
263
+ return lines.find((l) => pattern.test(l.fullText) && !(/Prior \d/.test(l.fullText) && /Overall Trend/i.test(l.fullText)));
264
+ }
265
+ function parseInventoryAnalysis(lines) {
266
+ const bb = {};
267
+ const startIdx = lines.findIndex((l) => /^Inventory Analysis/i.test(l.fullText));
268
+ const endIdx = lines.findIndex((l) => /^Median Sale & List Price/i.test(l.fullText));
269
+ const section = startIdx >= 0 && endIdx > startIdx ? lines.slice(startIdx, endIdx) : lines;
270
+ return {
271
+ totalComparableSales: parseTimePeriodRow(findRowByLabel(section, /Total # of Comparable Sales/i), bb, "invSales"),
272
+ absorptionRate: parseTimePeriodRow(findRowByLabel(section, /Absorption Rate/i), bb, "invAbsorption"),
273
+ totalActiveListings: parseTimePeriodRow(findRowByLabel(section, /Total # of Comparable Active Listings/i), bb, "invListings"),
274
+ monthsOfSupply: parseTimePeriodRow(findRowByLabel(section, /Months of Housing Supply/i), bb, "invSupply"),
275
+ boundingBoxes: bb,
276
+ };
277
+ }
278
+ function parseMedianSaleListData(lines) {
279
+ const bb = {};
280
+ const startIdx = lines.findIndex((l) => /^Median Sale & List Price/i.test(l.fullText));
281
+ const endIdx = lines.findIndex((l) => /Explain in detail the seller concessions/i.test(l.fullText));
282
+ const section = startIdx >= 0 && endIdx > startIdx ? lines.slice(startIdx, endIdx) : lines;
283
+ return {
284
+ medianSalePrice: parseTimePeriodRow(findRowByLabel(section, /^Median Comparable Sale Price\b/i), bb, "medSalePrice"),
285
+ medianSalesDaysOnMarket: parseTimePeriodRow(findRowByLabel(section, /Median Comparable Sales Days on Market/i), bb, "medDOM"),
286
+ medianListPrice: parseTimePeriodRow(findRowByLabel(section, /^Median Comparable List Price\b/i), bb, "medListPrice"),
287
+ medianListingsDaysOnMarket: parseTimePeriodRow(findRowByLabel(section, /Median Comparable Listings Days on Market/i), bb, "medListDOM"),
288
+ medianSalePriceAsPercentOfList: parseTimePeriodRow(findRowByLabel(section, /Median Sale Price as % of List/i), bb, "medPctList"),
289
+ sellerPaidFinancialAssistance: null,
290
+ sellerPaidFinancialAssistanceTrend: null,
291
+ boundingBoxes: bb,
292
+ };
293
+ }
294
+ function collectTextBetween(lines, startPattern, endPattern) {
295
+ let collecting = false;
296
+ const parts = [];
297
+ let startLine = null;
298
+ for (const line of lines) {
299
+ if (!collecting && startPattern.test(line.fullText)) {
300
+ collecting = true;
301
+ startLine = line;
302
+ const match = line.fullText.match(startPattern);
303
+ if (match && match.index !== undefined) {
304
+ const after = line.fullText.slice(match.index + match[0].length).trim();
305
+ if (after)
306
+ parts.push(after);
307
+ }
308
+ else
309
+ parts.push(line.fullText.trim());
310
+ continue;
311
+ }
312
+ if (collecting) {
313
+ if (endPattern.test(line.fullText))
314
+ break;
315
+ parts.push(line.fullText.trim());
316
+ }
317
+ }
318
+ return { text: parts.join(" ").trim(), startLine };
319
+ }
320
+ function parseMarketAnalysisText(lines) {
321
+ const bb = {};
322
+ const cr = collectTextBetween(lines, /Explain in detail the seller concessions/i, /^(N\/A|Are foreclosure sales)/i);
323
+ let sellerConcessionsExplanation = cr.text.replace(/^.*?etc\.\)\.\s*/is, "").trim();
324
+ if (cr.startLine?.segments[0])
325
+ bb.sellerConcessions = toBBox(cr.startLine.segments[0], cr.startLine);
326
+ const foreclosureLine = lines.find((l) => /Are foreclosure sales.*factor in the market/i.test(l.fullText));
327
+ let foreclosureSalesInMarket = null;
328
+ if (foreclosureLine) {
329
+ if (/\bNo\b/.test(foreclosureLine.fullText))
330
+ foreclosureSalesInMarket = false;
331
+ if (/\bYes\b/.test(foreclosureLine.fullText) && !/\bNo\b/.test(foreclosureLine.fullText))
332
+ foreclosureSalesInMarket = true;
333
+ bb.foreclosureSales = toBBox(foreclosureLine.segments[0], foreclosureLine);
334
+ }
335
+ let foreclosureExplanation = "";
336
+ const fIdx = lines.findIndex((l) => /Are foreclosure sales.*factor in the market/i.test(l.fullText));
337
+ if (fIdx >= 0) {
338
+ const parts = [];
339
+ for (let i = fIdx + 1; i < lines.length; i++) {
340
+ if (/^Cite data sources/i.test(lines[i].fullText))
341
+ break;
342
+ const t = lines[i].fullText.trim();
343
+ if (t)
344
+ parts.push(t);
345
+ }
346
+ foreclosureExplanation = parts.join(" ").trim();
347
+ }
348
+ const dr = collectTextBetween(lines, /Cite data sources for above/i, /Summarize the above information/i);
349
+ const dataSources = dr.text.replace(/^.*?information\.\s*/is, "").trim();
350
+ const sr = collectTextBetween(lines, /Summarize the above information/i, /If the subject is a unit in a condominium/i);
351
+ const summary = sr.text.replace(/^.*?support for your conclusions\.\s*/is, "").trim();
352
+ return { sellerConcessionsExplanation, foreclosureSalesInMarket, foreclosureExplanation, dataSources, summary, boundingBoxes: bb };
353
+ }
354
+ function parseCondoCoopProjects(lines) {
355
+ const bb = {};
356
+ const startIdx = lines.findIndex((l) => /If the subject is a unit in a condominium/i.test(l.fullText));
357
+ if (startIdx < 0)
358
+ return null;
359
+ const section = lines.slice(startIdx);
360
+ const projLine = section.find((l) => /Project Name/i.test(l.fullText));
361
+ let projectName = "";
362
+ if (projLine) {
363
+ const seg = projLine.segments.find((s) => /Project Name/i.test(s.text));
364
+ if (seg)
365
+ projectName = seg.text.replace(/^Project Name:?\s*/i, "").trim();
366
+ }
367
+ const gridStart = section.findIndex((l) => /^Subject Project Data/i.test(l.fullText));
368
+ const grid = gridStart >= 0 ? section.slice(gridStart) : section;
369
+ const emptyRow = () => ({ prior7to12Months: null, prior4to6Months: null, current3Months: null, overallTrend: null, boundingBoxes: {} });
370
+ const salesData = parseTimePeriodRow(findRowByLabel(grid, /Total # of Comparable Sales/i), bb, "condoSales");
371
+ const absData = parseTimePeriodRow(findRowByLabel(grid, /Absorption Rate/i), bb, "condoAbsorption");
372
+ const listingsData = parseTimePeriodRow(findRowByLabel(grid, /Total # of Active Comparable Listings/i), bb, "condoListings");
373
+ const supplyData = parseTimePeriodRow(findRowByLabel(grid, /Months of Unit Supply/i), bb, "condoSupply");
374
+ if (salesData.prior7to12Months === null && salesData.current3Months === null && !projectName)
375
+ return null;
376
+ return { projectName, totalComparableSales: salesData, absorptionRate: absData, totalActiveListings: listingsData, monthsOfUnitSupply: supplyData, foreclosureSalesInProject: null, foreclosureExplanation: "", summary: "", boundingBoxes: bb };
377
+ }
378
+ function parseMCAppraiserInfo(lines, supervisory) {
379
+ const bb = {};
380
+ const prefix = supervisory ? "sup" : "app";
381
+ const xThreshold = supervisory ? 300 : 0;
382
+ const xMax = supervisory ? 600 : 300;
383
+ let nameLine;
384
+ for (const l of lines) {
385
+ const hasLabel = l.segments.some((s) => s.x >= xThreshold && s.x < xMax && (supervisory ? /Supervisory Appraiser Name/i.test(s.text) : /^Appraiser Name/i.test(s.text.trim())));
386
+ if (hasLabel) {
387
+ nameLine = l;
388
+ break;
389
+ }
390
+ }
391
+ let name = "";
392
+ if (nameLine) {
393
+ const seg = nameLine.segments.find((s) => s.x >= xThreshold && s.x < xMax && !/Appraiser Name|Supervisory/i.test(s.text));
394
+ if (seg) {
395
+ name = seg.text.trim();
396
+ bb[`${prefix}Name`] = toBBox(seg, nameLine);
397
+ }
398
+ else {
399
+ const labelSeg = nameLine.segments.find((s) => s.x >= xThreshold && s.x < xMax && (supervisory ? /Supervisory Appraiser Name/i.test(s.text) : /Appraiser Name/i.test(s.text)));
400
+ if (labelSeg) {
401
+ name = labelSeg.text.replace(/^(Supervisory )?Appraiser Name\s*/i, "").trim();
402
+ bb[`${prefix}Name`] = toBBox(labelSeg, nameLine);
403
+ }
404
+ }
405
+ }
406
+ if (supervisory && !name)
407
+ return null;
408
+ function findFieldLine(label) {
409
+ return lines.find((l) => l.segments.some((s) => s.x >= xThreshold && s.x < xMax && label.test(s.text)));
410
+ }
411
+ function getField(label) {
412
+ const line = findFieldLine(label);
413
+ if (!line)
414
+ return "";
415
+ const seg = line.segments.find((s) => s.x >= xThreshold && s.x < xMax && label.test(s.text));
416
+ return seg ? seg.text.replace(label, "").trim() : "";
417
+ }
418
+ const companyName = getField(/^Company Name\s*/i);
419
+ const addrLine = findFieldLine(/^Company Address/i);
420
+ let companyAddress = "";
421
+ if (addrLine) {
422
+ const segs = addrLine.segments.filter((s) => s.x >= xThreshold && s.x < xMax && !/Company Address/i.test(s.text));
423
+ companyAddress = segs.length > 0 ? segs.map((s) => s.text.trim()).join(" ") : getField(/^Company Address\s*/i);
424
+ }
425
+ const licLine = findFieldLine(/^State License\/Certification/i);
426
+ let stateLicenseCertification = "", stateVal = "";
427
+ if (licLine) {
428
+ const licSeg = licLine.segments.find((s) => s.x >= xThreshold && s.x < xMax && /State License\/Certification/i.test(s.text));
429
+ if (licSeg)
430
+ stateLicenseCertification = licSeg.text.replace(/^State License\/Certification #\s*/i, "").trim();
431
+ const stateSeg = licLine.segments.find((s) => s.x >= xThreshold && s.x < xMax && /^State\s+/i.test(s.text) && !/License/i.test(s.text));
432
+ if (stateSeg)
433
+ stateVal = stateSeg.text.replace(/^State\s+/i, "").trim();
434
+ }
435
+ const email = getField(/^Email Address\s*/i);
436
+ return { name, companyName, companyAddress, stateLicenseCertification, state: stateVal, email, boundingBoxes: bb };
437
+ }