diff --git a/src/chord_diagram/chord_diagram.ts b/src/chord_diagram/chord_diagram.ts new file mode 100644 index 00000000..1ee2518b --- /dev/null +++ b/src/chord_diagram/chord_diagram.ts @@ -0,0 +1,222 @@ +import { Renderer } from './renderer'; + +type FingerNumber = 1 | 2 | 3 | 4 | 5; +type FretNumber = 1 | 2 | 3 | 4 | 5; +type StringNumber = 1 | 2 | 3 | 4 | 5 | 6; + +interface Barre { from: StringNumber, to: StringNumber, fret: FretNumber } +type StringList = StringNumber[]; +interface StringMarker { string: StringNumber, fret: FretNumber, finger?: FingerNumber } + +interface ChordDiagramOptions { + barres: Barre[]; + chord: string; + fretCount: number; + markers: StringMarker[]; + openStrings: StringList; + stringCount: number; + unusedStrings: StringList; +} + +function repeat(count: number, callback: (i: number) => void): void { + Array.from({ length: count }).forEach((_, i) => callback(i)); +} + +class ChordDiagram { + options: ChordDiagramOptions; + + renderer?: Renderer; + + titleY = 36; + + neckWidth = 240; + + neckHeight = 426; + + nutThickness = 10; + + fretThickness = 2; + + stringIndicatorSize = 28.8; + + constructor(options: ChordDiagramOptions) { + this.options = options; + } + + get neckX() { + return (this.stringIndicatorSize / 2) + 1; + } + + get neckY() { + return this.stringIndicatorY + 29.3; + } + + get stringIndicatorY() { + return this.titleY + 38.7; + } + + get fingerNumberIndicatorsY() { + return this.neckY + this.neckHeight + 36; + } + + get nutThicknessCorrection() { + return this.nutThickness - this.fretThickness; + } + + render(renderer: Renderer) { + this.renderTitle(renderer); + this.renderStrings(renderer); + this.renderNut(renderer); + this.renderFrets(renderer); + this.renderOpenStringIndicators(renderer); + this.renderUnusedStringIndicators(renderer); + this.renderStringMarkers(renderer); + this.renderBarres(renderer); + this.renderFingerNumberIndicators(renderer); + } + + renderTitle(renderer: Renderer) { + const { chord } = this.options; + + renderer.text( + chord, + { + fontSize: 48, + x: (this.neckX + (this.neckWidth / 2)), + y: this.titleY, + }, + ); + } + + renderStrings(renderer: Renderer) { + const { stringCount } = this.options; + + repeat(stringCount, (stringIndex) => { + renderer.line({ + x1: this.neckX + (stringIndex * (this.neckWidth / (stringCount - 1))), + y1: this.neckY, + x2: this.neckX + (stringIndex * (this.neckWidth / (stringCount - 1))), + y2: this.neckY + this.neckHeight, + thickness: 2, + }); + }); + } + + renderNut(renderer: Renderer) { + renderer.line({ + x1: this.neckX, + y1: this.neckY, + x2: this.neckX + this.neckWidth, + y2: this.neckY, + thickness: this.nutThickness, + }); + } + + renderFrets(renderer: Renderer) { + const { fretCount } = this.options; + const fretSpacing = (this.neckHeight - this.nutThicknessCorrection) / fretCount; + + repeat(fretCount, (fretIndex) => { + renderer.line({ + x1: this.neckX - this.fretThickness, + y1: this.neckY + this.nutThicknessCorrection + ((fretIndex + 1) * fretSpacing), + x2: this.neckX - this.fretThickness + this.neckWidth + (2 * this.fretThickness), + y2: this.neckY + this.nutThicknessCorrection + ((fretIndex + 1) * fretSpacing), + thickness: this.fretThickness, + }); + }); + } + + renderOpenStringIndicators(renderer: Renderer) { + const { openStrings, stringCount } = this.options; + + openStrings.forEach((stringNumber: StringNumber) => { + renderer.circle({ + size: this.stringIndicatorSize, + x: this.neckX + ((stringNumber - 1) * (this.neckWidth / (stringCount - 1))), + y: this.stringIndicatorY, + thickness: 2, + }); + }); + } + + renderUnusedStringIndicators(renderer: Renderer) { + const { stringCount, unusedStrings } = this.options; + + unusedStrings.forEach((stringNumber: StringNumber) => { + const x = this.neckX + ((stringNumber - 1) * (this.neckWidth / (stringCount - 1))); + const y = this.stringIndicatorY; + const size = this.stringIndicatorSize; + + renderer.line({ + x1: x - size / 2, + y1: y - size / 2, + x2: x + size / 2, + y2: y + size / 2, + thickness: 2, + }); + + renderer.line({ + x1: x + size / 2, + y1: y - size / 2, + x2: x - size / 2, + y2: y + size / 2, + thickness: 2, + }); + }); + } + + renderStringMarkers(renderer: Renderer) { + const { fretCount, markers } = this.options; + const fretSpacing = (this.neckHeight - this.nutThicknessCorrection) / fretCount; + + markers.forEach(({ string, fret }: StringMarker) => { + renderer.circle({ + x: this.neckX + ((string - 1) * (this.neckWidth / 5)), + y: this.neckY + this.nutThicknessCorrection + (fret * fretSpacing) - (fretSpacing / 2), + size: 31.2, + fill: true, + thickness: 2, + }); + }); + } + + renderBarres(renderer: Renderer) { + const { barres, fretCount, stringCount } = this.options; + const fretSpacing = (this.neckHeight - this.nutThicknessCorrection) / fretCount; + const barreHeight = fretSpacing / 3.0; + const stringSpacing = this.neckWidth / (stringCount - 1); + + barres.forEach(({ from, to, fret }: Barre) => { + const stringSpaceCount = to - from; + + renderer.rect({ + x: this.neckX + ((from - 0.5) * stringSpacing), + y: this.neckY + this.nutThicknessCorrection + ((fret - 0.5) * fretSpacing) - (barreHeight / 2), + width: stringSpaceCount * stringSpacing, + height: barreHeight, + thickness: 2, + radius: 15.6, + fill: true, + }); + }); + } + + renderFingerNumberIndicators(renderer: Renderer) { + const { markers, stringCount } = this.options; + const stringSpacing = this.neckWidth / (stringCount - 1); + + markers.forEach(({ string, finger }: StringMarker) => { + renderer.text( + `${finger}`, + { + fontSize: 28, + x: this.neckX + ((string - 1) * stringSpacing), + y: this.fingerNumberIndicatorsY, + }, + ); + }); + } +} + +export default ChordDiagram; diff --git a/src/chord_diagram/js_pdf_renderer.ts b/src/chord_diagram/js_pdf_renderer.ts new file mode 100644 index 00000000..5edacebd --- /dev/null +++ b/src/chord_diagram/js_pdf_renderer.ts @@ -0,0 +1,116 @@ +import JsPDF from 'jspdf'; +import { + DimensionOpts, + FillOpts, PositionOpts, RadiusOpts, Renderer, SizeOpts, ThicknessOpts, +} from './renderer'; + +class JsPDFRenderer implements Renderer { + doc: JsPDF; + + #x: number; + + #y: number; + + #scale: number; + + #defaultWidth = 280; + + #defaultHeight = 568; + + renderedHeight: number; + + constructor(doc: JsPDF, { x, y, width }: { x: number; y: number, width: number }) { + this.doc = doc; + this.#x = x; + this.#y = y; + this.#scale = width / this.#defaultWidth; + this.renderedHeight = this.#defaultHeight * this.#scale; + } + + scale(number: number) { + return number * this.#scale; + } + + tx(x: number) { + return this.#x + this.scale(x); + } + + ty(y: number) { + return this.#y + this.scale(y); + } + + circle({ + x, y, size, fill, thickness, + }: FillOpts & PositionOpts & SizeOpts & ThicknessOpts) { + this.withLineWidth(thickness, () => { + this.withDrawColor(0, () => { + this.doc.circle(this.tx(x), this.ty(y), this.scale(size / 2), fill ? 'F' : 'S'); + }); + }); + } + + line({ + x1, y1, x2, y2, thickness, + }: { x1: number, y1: number, x2: number, y2: number } & ThicknessOpts) { + this.withLineWidth(thickness, () => { + this.withDrawColor(0, () => { + this.doc.line(this.tx(x1), this.ty(y1), this.tx(x2), this.ty(y2)); + }); + }); + } + + rect({ + x, y, width, height, fill, thickness, radius, + }: DimensionOpts & FillOpts & PositionOpts & RadiusOpts & ThicknessOpts) { + this.withLineWidth(thickness, () => { + this.withDrawColor(0, () => { + this.doc.roundedRect( + this.tx(x), + this.ty(y), + this.scale(width), + this.scale(height), + this.scale(radius), + this.scale(radius), + fill ? 'F' : 'S', + ); + }); + }); + } + + text(text: string, { fontSize, x, y }: { fontSize: number, x: number, y: number }) { + this.withFontSize(fontSize, () => { + const textDimensions = this.doc.getTextDimensions(text); + this.doc.text(text, this.tx(x) - (textDimensions.w / 2), this.ty(y)); + }); + } + + withDrawColor(drawColor: number, callback: () => void) { + const previousDrawColor = this.doc.getDrawColor(); + this.doc.setDrawColor(drawColor); + callback(); + this.doc.setDrawColor(previousDrawColor); + } + + withFillColor(fillColor: string, callback: () => void) { + const previousFillColor = this.doc.getFillColor(); + this.doc.setFillColor(fillColor); + callback(); + this.doc.setFillColor(previousFillColor); + } + + withFontSize(fontSize: number, callback: () => void) { + const previousFontSize = this.doc.getFontSize(); + this.doc.setFontSize(this.scale(fontSize)); + callback(); + this.doc.setFontSize(previousFontSize); + } + + withLineWidth(lineWidth: number, callback: () => void) { + const previousLineWidth = this.doc.getLineWidth(); + this.doc.setLineWidth(this.scale(lineWidth)); + callback(); + this.doc.setLineWidth(previousLineWidth); + } +} + +export default JsPDFRenderer; diff --git a/src/chord_diagram/renderer.ts b/src/chord_diagram/renderer.ts new file mode 100644 index 00000000..f24bbcab --- /dev/null +++ b/src/chord_diagram/renderer.ts @@ -0,0 +1,24 @@ +export interface RadiusOpts { radius: number } +export interface FillOpts { fill?: boolean } +interface HeightOpts { height: number } +export interface PositionOpts { x: number, y: number } +export interface SizeOpts { size: number } +export interface ThicknessOpts { thickness: number } +interface WidthOpts { width: number } +export type DimensionOpts = WidthOpts & HeightOpts; + +export interface Renderer { + circle({ + x, y, size, fill, thickness, + }: FillOpts & PositionOpts & SizeOpts & ThicknessOpts): void + + line({ + x1, y1, x2, y2, thickness, + }: { x1: number, y1: number, x2: number, y2: number } & ThicknessOpts): void + + rect({ + x, y, width, height, fill, thickness, radius, + }: DimensionOpts & FillOpts & PositionOpts & RadiusOpts & ThicknessOpts): void + + text(text: string, { fontSize, x, y } : { fontSize: number, x: number, y: number }): void; +} diff --git a/src/chord_diagram/svg_renderer.ts b/src/chord_diagram/svg_renderer.ts new file mode 100644 index 00000000..920350f9 --- /dev/null +++ b/src/chord_diagram/svg_renderer.ts @@ -0,0 +1,99 @@ +import { + DimensionOpts, FillOpts, PositionOpts, RadiusOpts, Renderer, SizeOpts, ThicknessOpts, +} from './renderer'; + +type ViewBox = [number, number, number, number]; + +class SVGRenderer implements Renderer { + content = ''; + + viewBox: ViewBox; + + constructor({ viewBox }: { viewBox: ViewBox }) { + this.viewBox = viewBox; + } + + output() { + return this.svgWrapper(this.content); + } + + svgWrapper(innerHTML: string) { + return ` + + ${innerHTML} + + `; + } + + circle({ + x, y, size, fill, thickness, + }: FillOpts & PositionOpts & SizeOpts & ThicknessOpts) { + this.content += ` + + `; + } + + line({ + x1, y1, x2, y2, thickness, + }: { + x1: number, y1: number, x2: number, y2: number, + } & ThicknessOpts): void { + this.content += ` + + `; + } + + rect({ + x, y, width, height, fill, thickness, radius, + }: DimensionOpts & FillOpts & PositionOpts & RadiusOpts & ThicknessOpts): void { + this.content += ` + + `; + } + + text(text: string, { fontSize, x, y } : { fontSize: number, x: number, y: number }): void { + this.content += ` + ${text} + `; + } +} + +export default SVGRenderer; diff --git a/src/formatter/pdf_formatter.ts b/src/formatter/pdf_formatter.ts index 9df1f98c..096a81d0 100644 --- a/src/formatter/pdf_formatter.ts +++ b/src/formatter/pdf_formatter.ts @@ -50,6 +50,8 @@ import { Performance } from 'perf_hooks'; import { Blob } from 'buffer'; import Configuration, { defaultConfiguration } from './configuration'; import { getCapos } from '../helpers'; +import JsPDFRenderer from '../chord_diagram/js_pdf_renderer'; +import ChordDiagram from '../chord_diagram/chord_diagram'; declare const performance: Performance; class PdfFormatter extends Formatter { @@ -93,11 +95,12 @@ class PdfFormatter extends Formatter { this.configuration = configuration; this.pdfConfiguration = pdfConfiguration; this.doc = this.setupDoc(docConstructor); - this.margins = this.pdfConfiguration.layout.global.margins; + this.y = this.margins.top + this.pdfConfiguration.layout.header.height; this.x = this.margins.left; this.currentColumn = 1; - this.formatParagraphs(); + // this.formatParagraphs(); + this.renderChordDiagram(); this.recordFormattingTime(); // Must render the footer and header after all formatting @@ -110,6 +113,33 @@ class PdfFormatter extends Formatter { } } + renderChordDiagram() { + const chordDiagram = new ChordDiagram({ + barres: [ + { from: 3, to: 6, fret: 1 }, + { from: 1, to: 5, fret: 5 }, + ], + chord: 'Bm7', + markers: [ + { string: 2, fret: 1, finger: 3 }, + { string: 3, fret: 2, finger: 4 }, + { string: 4, fret: 3, finger: 2 }, + { string: 5, fret: 4, finger: 1 }, + { string: 6, fret: 5, finger: 5 }, + ], + fretCount: 5, + stringCount: 6, + openStrings: [6], + unusedStrings: [1, 2, 3, 4, 5], + }); + + const renderer = new JsPDFRenderer(this.doc, { x: 50, y: 100, width: 140 }); + console.log(`rendered height: ${renderer.renderedHeight}`); + chordDiagram.render(renderer); + this.doc.setDrawColor(0); + this.doc.rect(50, 100, 140, renderer.renderedHeight, 'S'); + } + // Save the formatted document as a PDF file save(): void { this.doc.save(`${this.song.title || 'untitled'}.pdf`);