Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to update table of content page after all sections are created? #1551

Open
Rajgupta7080 opened this issue Sep 25, 2024 · 1 comment
Open

Comments

@Rajgupta7080
Copy link

I am generating a PDF for reports that includes a table of contents and page numbers. However, we can only determine the page numbers after generating all sections. I have also attempted to use doc.switchToPage().

@NathanSavageKaimai
Copy link

Ive run into exactly this issue. There are two ways to approach this. Ethier you can pre-calculate your PDF content per page and create the page all in one (which is the solution I took) or when creating the PDFDocument class instance, you can add the paramter bufferPages: true. This means that when you add a new page, the old one is not flushed to the output and you can revisit it. This setting allows you to use doc.switchToPage(). For more details, look at the Switching to previous pages heading on the website

As an aside, here is the code for the pdf planning class I created. It plans a 2 column layout with a few different types of content. I think ideally this is the better solution as with a large PDF with alot of pages. Having to return to the first at the end may consume alot of memory.

//booklet-layout.ts
import { Container, Search, SearchItem, Sponsorship } from 'fido-common';
import { instanceConfig, tokens } from '../../../constants.js';
import PDFDocument from 'pdfkit';
import { Utils } from './utils.js';

export enum fontTypes {
  title,
  content,
  sub,
}

export enum planColumns {
  left,
  right,
}

export enum planTokens {
  contents,
  summary,
  article,
  sponsor,
}
export type planColumn = { availableHeight: number; items: placedPlanItem[] };
export type planPage = Record<planColumns, planColumn>;

type planItem = { height: number } & (
  | { search: Search; type: planTokens.contents }
  | { summary: string; type: planTokens.summary }
  | { image?: Buffer; searchItem: SearchItem; type: planTokens.article }
  | { image: Buffer; sponsor: Sponsorship; type: planTokens.sponsor }
);

export type placedPlanItem = { yPos: number } & planItem;

export class BookletLayoutPlan {
  static adQrSize = 50;
  static columnImageMargin = 10;

  static columnOffsets: Record<planColumns, number> = {
    [planColumns.left]: 10,
    [planColumns.right]: 302,
  };

  static columnWidth = 282;

  private config: instanceConfig;

  static fontSizes: Record<fontTypes, number> = {
    [fontTypes.title]: 16,
    [fontTypes.content]: 10,
    [fontTypes.sub]: 8,
  };

  static headerOffset: Record<planColumns, number> = {
    [planColumns.left]: 10,
    [planColumns.right]: 345,
  };

  static headerWidth = 240;

  static idealSpacing = 50;
  static minimumSpacing = 20;

  static pageHeight = 840;
  static pageWidth = 595;
  static pageYmargin = 40;

  private plan: planPage[];

  private get planPageTemplate(): planPage {
    return {
      [planColumns.left]: {
        availableHeight: BookletLayoutPlan.columnHeight,
        items: [],
      },
      [planColumns.right]: {
        availableHeight: BookletLayoutPlan.columnHeight,
        items: [],
      },
    };
  }

  constructor() {
    this.plan = [this.planPageTemplate];
    const container = Container.getInstance();
    this.config = container.resolve<instanceConfig>(tokens.instance);
  }

  static get columnHeight(): number {
    return this.pageHeight - this.pageYmargin - this.pageYmargin;
  }

  static get columnImageWidth(): number {
    return this.columnWidth - this.columnImageMargin * 2;
  }

  async buildPlan(
    search: Search,
    doc: typeof PDFDocument,
    articleImages: Record<string, Buffer>,
    sponsorImage: Buffer | undefined,
  ): Promise<planPage[]> {
    const planItems = await this.createPlanItems(
      search,
      doc,
      articleImages,
      sponsorImage,
    );
    //place items
    for (const item of planItems) {
      if (item.height > 0 && item.height < BookletLayoutPlan.columnHeight) {
        let placed = false;
        for (const page of this.plan) {
          const spaceLeft = page[planColumns.left].availableHeight;
          const spaceRight = page[planColumns.right].availableHeight;
          const couldFit =
            Math.max(spaceLeft, spaceRight) >
            item.height + BookletLayoutPlan.minimumSpacing;
          if (spaceLeft >= spaceRight && couldFit) {
            page[planColumns.left].items.push({ yPos: 0, ...item });
            page[planColumns.left].availableHeight -=
              item.height + BookletLayoutPlan.minimumSpacing;
            placed = true;
            break;
          } else if (couldFit) {
            page[planColumns.right].items.push({ yPos: 0, ...item });
            page[planColumns.right].availableHeight -=
              item.height + BookletLayoutPlan.minimumSpacing;
            placed = true;
            break;
          }
        }
        if (!placed) {
          this.plan.push(this.planPageTemplate);
          const lastPage = this.plan[this.plan.length - 1];
          lastPage[planColumns.left].items.push({ yPos: 0, ...item });
          lastPage[planColumns.left].availableHeight -=
            item.height + BookletLayoutPlan.minimumSpacing;
        }
      }
    }
    //distribute items
    for (const page of this.plan) {
      this.spaceColumn(page[planColumns.left]);
      this.spaceColumn(page[planColumns.right]);
    }
    return this.plan;
  }

  private async createPlanItems(
    search: Search,
    doc: typeof PDFDocument,
    articleImages: Record<string, Buffer>,
    sponsorImage: Buffer | undefined,
  ): Promise<planItem[]> {
    const planItems: planItem[] = [];
    const contentsHeight = Utils.estimateContentsHeight(
      doc,
      search.searchItems!,
    );
    planItems.push({
      height: contentsHeight,
      search,
      type: planTokens.contents,
    });
    if (search.summary?.enabled && search.summary?.successful) {
      const summaryHeight = Utils.estimateSummaryHeight(
        doc,
        search.summary.summary!,
      );
      planItems.push({
        summary: search.summary!.summary!,
        height: summaryHeight,
        type: planTokens.summary,
      });
    }
    const articlePlanItems = await Promise.all(
      search.searchItems?.map(async (searchItem) => {
        const height = await Utils.estimatArticleHeight(
          searchItem,
          articleImages,
          doc,
        );
        let image: Buffer | undefined = undefined;
        if (searchItem.articleImage) {
          image = articleImages[searchItem.articleImage.publicBlobUrl!];
        }
        return {
          height,
          image,
          searchItem,
          type: planTokens.article,
        };
      }) as Promise<planItem>[],
    );
    let sponsorPlanItem: planItem | undefined = undefined;
    if (search.sponsorship && sponsorImage) {
      const height = await Utils.estimateSponsorHeight(sponsorImage);
      sponsorPlanItem = {
        height,
        image: sponsorImage,
        sponsor: search.sponsorship,
        type: planTokens.sponsor,
      };
    }
    for (const i in articlePlanItems) {
      planItems.push(articlePlanItems[i]);
      if (
        sponsorPlanItem &&
        Number.parseInt(i) % this.config.articlesPerSponsor == 0
      ) {
        planItems.push(sponsorPlanItem);
      }
    }
    return planItems;
  }

  private spaceColumn(column: planColumn): void {
    const totalWhitespace =
      BookletLayoutPlan.columnHeight -
      column.items.reduce((acc, cur) => acc + cur.height, 0);
    const spaceBetween = Math.min(
      totalWhitespace / (column.items.length - 1),
      BookletLayoutPlan.idealSpacing,
    );
    for (let i = 0; i < column.items.length; i++) {
      if (i == 0) {
        column.items[i].yPos = 0;
      } else {
        const bottomOfPrevious =
          column.items[i - 1].yPos + column.items[i - 1].height;
        column.items[i].yPos = bottomOfPrevious + spaceBetween;
      }
    }
  }
}





//utils.ts
import { BookletLayoutPlan } from './booklet-layout-plan.js';
import PDFDocument from 'pdfkit';
import { SearchItem } from 'fido-common';
import { fontTypes } from './/booklet-layout-plan.js';
import sharp from 'sharp';

export class Utils {
  static async estimatArticleHeight(
    article: SearchItem,
    articleImages: Record<string, Buffer>,
    doc: typeof PDFDocument,
  ): Promise<number> {
    let imgHeight = 0;
    if (
      article.articleImage &&
      articleImages[article.articleImage.publicBlobUrl!]
    ) {
      const articleImage = articleImages[article.articleImage.publicBlobUrl!];
      const img = await sharp(articleImage);
      const imageMeta = await img.metadata();
      const imgRatio = (imageMeta.height ?? 1) / (imageMeta.width ?? 1);
      imgHeight = imgRatio * BookletLayoutPlan.columnImageWidth;
    }
    return (
      doc.fontSize(16).heightOfString(article.article!.title!, {
        width: BookletLayoutPlan.columnWidth,
      }) +
      imgHeight +
      doc.fontSize(10).heightOfString(article!.article!.summary!, {
        width: BookletLayoutPlan.columnWidth,
      }) +
      doc.currentLineHeight(true) * 0.6 +
      doc.fontSize(8).heightOfString(article.article!.sourceLink!, {
        width: BookletLayoutPlan.columnWidth,
      })
    );
  }

  static estimateContentsHeight(
    doc: typeof PDFDocument,
    searchItems: SearchItem[],
  ): number {
    let rollingHeight = this.setFont(
      doc,
      'black',
      'Helvetica-Bold',
      fontTypes.title,
    ).heightOfString('Contents: ', {
      width: BookletLayoutPlan.columnImageWidth,
    });
    this.setFont(doc, 'black', 'Helvetica', fontTypes.content);
    for (const item of searchItems) {
      rollingHeight += doc.heightOfString('• ' + item.article!.title, {
        width: BookletLayoutPlan.columnImageWidth,
      });
    }
    return (rollingHeight += 20);
  }

  static async estimateSponsorHeight(sponsorImage: Buffer): Promise<number> {
    const img = await sharp(sponsorImage);
    const imageMeta = await img.metadata();
    const imgRatio = (imageMeta.height ?? 1) / (imageMeta.width ?? 1);
    return imgRatio * BookletLayoutPlan.columnImageWidth;
  }

  static estimateSummaryHeight(
    doc: typeof PDFDocument,
    summary: string,
  ): number {
    const summaryParagraphs = summary.split('\n').filter((pg) => pg);
    let rollingHeight = this.setFont(
      doc,
      'black',
      'Helvetica',
      fontTypes.title,
    ).heightOfString('Summary:', { width: BookletLayoutPlan.columnWidth });
    this.setFont(doc, 'black', 'Helvetica', fontTypes.content);
    summaryParagraphs.forEach((paragraph) => {
      rollingHeight += doc.heightOfString(paragraph, {
        width: BookletLayoutPlan.columnWidth,
      });
    });
    rollingHeight +=
      (summaryParagraphs.length - 1) * (doc.currentLineHeight(false) * 0.5);
    return rollingHeight;
  }

  static setFont(
    doc: typeof PDFDocument,
    colour: PDFKit.Mixins.ColorValue,
    font: PDFKit.Mixins.PDFFontSource,
    type: fontTypes,
  ): PDFKit.PDFDocument {
    return doc
      .fillAndStroke(colour, colour)
      .font(font, BookletLayoutPlan.fontSizes[type]);
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants