Skip to content

Commit

Permalink
feat(core/forms): download forms as docx
Browse files Browse the repository at this point in the history
  • Loading branch information
robzan8 committed Feb 29, 2024
1 parent 8d81f4a commit e1f2db5
Show file tree
Hide file tree
Showing 5 changed files with 403 additions and 1 deletion.
385 changes: 385 additions & 0 deletions projects/core/forms/src/form-to-pdf/form-to-doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,385 @@
/**
* @license
* Copyright (C) Gnucoop soc. coop.
*
* This file is part of the Advanced JSON forms (ajf).
*
* Advanced JSON forms (ajf) is free software: you can redistribute it and/or
* modify it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* Advanced JSON forms (ajf) is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
* General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Advanced JSON forms (ajf).
* If not, see http://www.gnu.org/licenses/.
*
*/

import {AjfContext, evaluateExpression} from '@ajf/core/models';
import {
BorderStyle,
Document,
HeadingLevel,
ITableCellBorders,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
} from 'docx';

import {AjfChoice} from '../interface/choices/choice';
import {AjfEmptyField} from '../interface/fields/empty-field';
import {AjfField} from '../interface/fields/field';
import {AjfFieldType} from '../interface/fields/field-type';
import {AjfTableField} from '../interface/fields/table-field';
import {AjfForm} from '../interface/forms/form';
import {AjfNodeType} from '../interface/nodes/node-type';
import {AjfRepeatingSlide} from '../interface/slides/repeating-slide';
import {AjfSlide} from '../interface/slides/slide';
import {isField} from '../utils/nodes/is-field';
import {isRepeatingSlide} from '../utils/nodes/is-repeating-slide';
import {isSlideNode} from '../utils/nodes/is-slide-node';

function downloadBlob(b: Blob) {
const url = URL.createObjectURL(b);
const a = document.createElement('a');
a.setAttribute('style', 'display: none');
a.href = url;
a.target = '_blank';
a.download = 'form.docx';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

type TranslateFunc = (text: string) => string;

export function downloadFormDoc(
form: AjfForm,
translate?: TranslateFunc,
header?: Paragraph[],
context?: AjfContext
) {
createFormDoc(form, translate, header, context).then(blob => {
downloadBlob(blob);
});
}

export function createFormDoc(
form: AjfForm,
translate?: TranslateFunc,
header?: Paragraph[],
context?: AjfContext
): Promise<Blob> {
return new Promise<Blob>(resolve => {
const t = translate ? translate : (s: string) => s;
const doc = formToDoc(form, t, header, context);
Packer.toBlob(doc).then(blob => resolve(blob));
});
}

// ChoicesMap maps a choicesOriginRef to the list the choices.
interface ChoicesMap {
[name: string]: AjfChoice<any>[];
}

function stripHTML(s: string): string {
return s.replace(/<\/?[^>]+(>|$)/g, '');
}

// Given a context, lookupStringFunction returns a function that allows to retrieve
// the field values from the context. The values are returned as doc-friendly strings.
// rep is the index of the repeating slide, if the field belongs to one.
function lookupStringFunction(context?: AjfContext, rep?: number): (name: string) => string {
if (context == null) {
return (_: string) => '';
}
return (name: string) => {
if (name == null) {
return '';
}
if (rep != null) {
name = name + '__' + rep;
}
const val = context[name];
if (val == null) {
return '';
}
if (val === true) {
return 'yes';
}
if (val === false) {
return 'no';
}
return String(val);
};
}

// Analogous to lookupStringFunction, but for multiple-choice questions,
// returning an array of values.
function lookupArrayFunction(context?: AjfContext, rep?: number): (name: string) => string[] {
if (context == null) {
return (_: string) => [];
}
return (name: string) => {
if (name == null) {
return [];
}
if (rep != null) {
name = name + '__' + rep;
}
const val = context[name];
if (Array.isArray(val)) {
return val;
}
return [];
};
}

type SectionChild = Paragraph | Table;

function formToDoc(
form: AjfForm,
translate: (s: string) => string,
header?: Paragraph[],
context?: AjfContext,
): Document {
const choicesMap: ChoicesMap = {};
for (const o of form.choicesOrigins) {
choicesMap[o.name] = o.choices;
}

const children: SectionChild[] = header ? [...header] : [];
for (const slide of form.nodes) {
if (isSlideNode(slide)) {
children.push(...slideToDoc(slide, choicesMap, translate, context));
} else if (isRepeatingSlide(slide)) {
children.push(...repeatingSlideToDoc(slide, choicesMap, translate, context));
}
}
return new Document({sections: [{children}]});
}

function slideToDoc(
slide: AjfSlide | AjfRepeatingSlide,
choicesMap: ChoicesMap,
translate: (s: string) => string,
context?: AjfContext,
rep?: number,
): SectionChild[] {
let label = translate(slide.label);
if (rep != null) {
label = `${label} (${translate('repeat')} ${rep + 1})`;
}
const children: SectionChild[] = [new Paragraph({text: label, heading: HeadingLevel.HEADING_2})];
for (const field of slide.nodes) {
if (isField(field)) {
children.push(...fieldToDoc(field, choicesMap, translate, context, rep));
}
}
return children;
}

function repeatingSlideToDoc(
slide: AjfRepeatingSlide,
choicesMap: ChoicesMap,
translate: (s: string) => string,
context?: AjfContext,
): SectionChild[] {
let repeats = 3; // default, if no formData
const maxRepeats = 20;
if (context != null && slide.name != null) {
const r = context[slide.name];
if (typeof r === 'number') {
repeats = Math.min(r, maxRepeats);
}
}

const children = [];
for (let r = 0; r < repeats; r++) {
children.push(...slideToDoc(slide, choicesMap, translate, context, r));
}
return children;
}

function tableCell(text: string, borders?: ITableCellBorders): TableCell {
return new TableCell({children: [new Paragraph(text)], borders});
}

const tableWidth = 9000;

function singleColTable(text: string) {
return new Table({
columnWidths: [tableWidth],
rows: [new TableRow({children: [tableCell(text)]})],
});
}

const noBorder = {style: BorderStyle.NONE};
const noBorders = {top: noBorder, bottom: noBorder, left: noBorder, right: noBorder};

function doubleColTable(l: string, r: string) {
return new Table({
columnWidths: [tableWidth/2, tableWidth/2],
rows: [new TableRow({children: [tableCell(l, noBorders), tableCell(r)]})],
});
}

const marginAfterFields = new Paragraph('');

function fieldToDoc(
field: AjfField | AjfEmptyField,
choicesMap: ChoicesMap,
translate: (s: string) => string,
context?: AjfContext,
rep?: number,
): SectionChild[] {
if (field.nodeType !== AjfNodeType.AjfField) {
throw new Error('not a field');
}

const visible =
context == null /* form not compiled, show all fields */ ||
field.visibility == null ||
evaluateExpression(field.visibility.condition, context);
if (!visible) {
return [];
}

const lookupString = lookupStringFunction(context, rep);

switch (field.fieldType) {
case AjfFieldType.String:
case AjfFieldType.Text:
return [
new Paragraph(translate(field.label)),
singleColTable(lookupString(field.name)),
marginAfterFields,
];
case AjfFieldType.Formula:
let value = lookupString(field.name);
if (value === '') {
// If the value of the field is not in the context, recompute the formula.
const formula = field.formula || {formula: ''};
value = String(evaluateExpression(formula.formula, context));
}
return [
new Paragraph(translate(field.label)),
singleColTable(value),
marginAfterFields,
];
case AjfFieldType.Number:
case AjfFieldType.Boolean:
case AjfFieldType.DateInput:
case AjfFieldType.Time:
case AjfFieldType.Range:
let val = lookupString(field.name);
// for boolean fields in compiled forms, a null value is printed as 'no':
if (field.fieldType === AjfFieldType.Boolean && context != null && val === '') {
val = 'no';
}
return [doubleColTable(translate(field.label), val), marginAfterFields];
case AjfFieldType.SingleChoice:
case AjfFieldType.MultipleChoice:
const choices = choicesMap[(field as any).choicesOriginRef];
if (context == null) {
// empty form
return [...choiceToDoc(field, choices, translate), marginAfterFields];
}
// compiled form, only print choices that are selected
const selectedValues =
field.fieldType === AjfFieldType.SingleChoice
? [lookupString(field.name)]
: lookupArrayFunction(context, rep)(field.name);
const nonNullChoice = (c: AjfChoice<any> | undefined): c is AjfChoice<any> => c != null;
let selectedChoices = selectedValues
.map(v => choices.find(c => c.value === v))
.filter(nonNullChoice);
if (selectedChoices.length === 0) {
selectedChoices = selectedValues.map(v => ({
label: v,
value: v,
}));
}
return [...choiceToDoc(field, selectedChoices, translate, context), marginAfterFields];
case AjfFieldType.Empty:
return [new Paragraph(stripHTML(translate(field.HTML))), marginAfterFields];
case AjfFieldType.Table:
return [...tableToPdf(field, lookupString, translate), marginAfterFields];
default:
// yet unsupported field type
return [];
}
}

function choiceToDoc(
field: AjfField,
choices: AjfChoice<any>[],
translate: (s: string) => string,
context?: AjfContext,
): SectionChild[] {
let question = translate(field.label);
// If the form is empty (to be compiled),
// help the user distinguish between single- and multiple-choice questions:
if (context == null && field.fieldType === AjfFieldType.SingleChoice) {
question += ` (${translate('single choice')})`;
}
if (context == null && field.fieldType === AjfFieldType.MultipleChoice) {
question += ` (${translate('multiple choice')})`;
}
let choiceLabels: string[];
if (choices == null || choices.length === 0) {
choiceLabels = [''];
} else {
choiceLabels = choices.map(c => c.label);
}
const rows = [new TableRow({children: [
new TableCell({
children: [new Paragraph(question)],
rowSpan: choiceLabels.length,
borders: noBorders
}),
tableCell(translate(choiceLabels[0])),
]})];
for (let i = 1; i < choiceLabels.length; i++) {
rows.push(new TableRow({children: [tableCell(translate(choiceLabels[i]))]}));
}
return [new Table({columnWidths: [tableWidth/2, tableWidth/2], rows})];
}

function tableToPdf(
table: AjfTableField,
lookupString: (s: string) => string,
translate: (s: string) => string,
): SectionChild[] {
const rows = [new TableRow({children: [
tableCell(''),
...table.columnLabels.map(label => tableCell(translate(label))),
]})];
for (let i = 0; i < table.rows.length; i++) {
const row = [...table.rows[i]];
for (let j = 0; j < row.length; j++) {
const cell = row[j];
if (typeof cell !== 'string') {
row[j] = cell.formula;
}
}
const valsRow = (row as string[]).map(lookupString).map(translate);
rows.push(new TableRow({children: [
tableCell(translate(table.rowLabels[i])),
...valsRow.map(val => tableCell(val)),
]}));
}
const cols = table.columnLabels.length + 1;
return [
new Paragraph(translate(table.label)),
new Table({columnWidths: Array(cols).fill(tableWidth / cols), rows}),
];
}
2 changes: 1 addition & 1 deletion projects/core/forms/src/form-to-pdf/form-to-pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ function choiceToPdf(
question += ` (${translate('single choice')})`;
}
if (context == null && field.fieldType === AjfFieldType.MultipleChoice) {
question += ` (${translate('multipe choice')})`;
question += ` (${translate('multiple choice')})`;
}
return [
{
Expand Down

0 comments on commit e1f2db5

Please sign in to comment.