main.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: BSD-3-Clause
  4
  5package main
  6
  7import (
  8	"bytes"
  9	"embed"
 10	"fmt"
 11	"image"
 12	"image/color"
 13	"image/png"
 14	"os"
 15	"strings"
 16
 17	"gioui.org/font/opentype"
 18	"gioui.org/gpu/headless"
 19	"gioui.org/io/router"
 20	"gioui.org/layout"
 21	"gioui.org/op"
 22	"gioui.org/op/clip"
 23	"gioui.org/op/paint"
 24	"gioui.org/text"
 25	"gioui.org/unit"
 26	"gioui.org/widget/material"
 27	"github.com/adrg/frontmatter"
 28	flag "github.com/spf13/pflag"
 29	"gopkg.in/yaml.v3"
 30)
 31
 32var (
 33	flagHelp          *bool   = flag.BoolP("help", "h", false, "Show the help message")
 34	flagInput         *string = flag.StringP("input", "i", "", "Path to input Markdown")
 35	flagOutput        *string = flag.StringP("output", "o", "", "Path to output PNG")
 36	flagMetaSize      *int    = flag.IntP("metasize", "M", 40, "Size of font for meta information")
 37	flagPostTitleSize *int    = flag.IntP("posttitlesize", "P", 60, "Size of font for post title")
 38	flagSiteTitleSize *int    = flag.IntP("sitetitlesize", "S", 50, "Size of font for site title")
 39	flagTitle         *string = flag.StringP("posttitle", "t", "", "Title displayed in the generated image")
 40	flagSubtitle      *string = flag.StringP("subtitle", "s", "", "Subtitle displayed in the generated image")
 41)
 42
 43//go:embed fonts
 44var fontEmbed embed.FS
 45
 46func main() {
 47	flag.Parse()
 48
 49	if *flagHelp {
 50		help()
 51		os.Exit(0)
 52	}
 53
 54	manual := validateFlags()
 55
 56	var postTitle, postDate, postContent, postReadTime, dateEdited string
 57
 58	if !manual {
 59		postTitle, postDate, postContent = getPostInfo(*flagInput)
 60		postReadTime = getReadTime(postContent)
 61		dateEdited = getEditDate(*flagInput)
 62	}
 63
 64	siteTitle := getSiteTitle()
 65
 66	collection := fontCollection()
 67
 68	const scale = 1
 69	sz := image.Point{X: 1200 * scale, Y: 630 * scale}
 70
 71	w, err := headless.NewWindow(sz.X, sz.Y)
 72	if err != nil {
 73		panic(err)
 74	}
 75	th := material.NewTheme(collection)
 76	gtx := layout.Context{
 77		Ops:         new(op.Ops),
 78		Queue:       new(router.Router),
 79		Constraints: layout.Exact(sz),
 80		Metric: unit.Metric{
 81			PxPerDp: scale,
 82			PxPerSp: scale,
 83		},
 84	}
 85	paint.Fill(gtx.Ops, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
 86	layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
 87		gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .6)
 88		gtx.Constraints.Min.X = gtx.Constraints.Max.X
 89		gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
 90
 91		return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle, Spacing: layout.SpaceAround}.Layout(gtx,
 92
 93			layout.Rigid(layout.Spacer{Height: 50}.Layout),
 94
 95			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 96				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
 97				gtx.Constraints.Min.X = gtx.Constraints.Max.X
 98				var title material.LabelStyle
 99				if manual {
100					title = material.Label(th, unit.Sp(float32(*flagPostTitleSize)), *flagTitle)
101				} else {
102					title = material.Label(th, unit.Sp(float32(*flagPostTitleSize)), postTitle)
103				}
104				title.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
105				title.Alignment = text.Middle
106				return title.Layout(gtx)
107			}),
108
109			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
110				if manual {
111					subtitle := material.Label(th, unit.Sp(float32(*flagMetaSize)), *flagSubtitle)
112					subtitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Italic, Weight: text.Normal}
113					subtitle.Alignment = text.Middle
114					return subtitle.Layout(gtx)
115				} else {
116					gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .7)
117					gtx.Constraints.Min.X = gtx.Constraints.Max.X
118					return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
119						layout.Rigid(func(gtx layout.Context) layout.Dimensions {
120							return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
121								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
122									rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Reading Time: ")
123									rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
124									rTime.Alignment = text.Start
125									return rTime.Layout(gtx)
126								}),
127								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
128									rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), postReadTime)
129									rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
130									rTime.Alignment = text.End
131									return rTime.Layout(gtx)
132								}),
133							)
134						}),
135
136						layout.Rigid(func(gtx layout.Context) layout.Dimensions {
137							return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
138								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
139									pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Published: ")
140									pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
141									pDate.Alignment = text.Start
142									return pDate.Layout(gtx)
143								}),
144								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
145									pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postDate))
146									pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
147									pDate.Alignment = text.End
148									return pDate.Layout(gtx)
149								}),
150							)
151						}),
152
153						layout.Rigid(func(gtx layout.Context) layout.Dimensions {
154							return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
155								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
156									eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Edited: ")
157									eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
158									eDate.Alignment = text.Start
159									return eDate.Layout(gtx)
160								}),
161								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
162									eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(dateEdited))
163									eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
164									eDate.Alignment = text.End
165									return eDate.Layout(gtx)
166								}),
167							)
168						}),
169					)
170				}
171			}),
172
173			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
174				size := image.Pt(gtx.Constraints.Max.X, 4)
175				defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
176				paint.ColorOp{Color: color.NRGBA{A: 0xFF}}.Add(gtx.Ops)
177				paint.PaintOp{}.Add(gtx.Ops)
178				return layout.Dimensions{Size: size}
179			}),
180
181			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
182				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
183				gtx.Constraints.Min.X = gtx.Constraints.Max.X
184				sTitle := material.Label(th, unit.Sp(float32(*flagSiteTitleSize)), siteTitle)
185				sTitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
186				sTitle.Alignment = text.Middle
187				return sTitle.Layout(gtx)
188			}),
189			layout.Rigid(layout.Spacer{Height: 50}.Layout),
190		)
191	})
192	err = w.Frame(gtx.Ops)
193	if err != nil {
194		fmt.Println("Error: Could not render GUI")
195		fmt.Println(err)
196		os.Exit(1)
197	}
198	img := image.NewRGBA(image.Rectangle{Max: sz})
199	err = w.Screenshot(img)
200	if err != nil {
201		fmt.Println("Error: Could not transfer GUI to image")
202		fmt.Println(err)
203		os.Exit(1)
204	}
205	var buf bytes.Buffer
206	if err := png.Encode(&buf, img); err != nil {
207		fmt.Println("Error: Could not encode image to PNG")
208		fmt.Println(err)
209		os.Exit(1)
210	}
211	// Write the image to a file
212	err = os.WriteFile(*flagOutput, buf.Bytes(), 0o644)
213	if err != nil {
214		fmt.Println("Error: Could not write image to file")
215		fmt.Println(err)
216		os.Exit(1)
217	}
218
219	os.Exit(0)
220}
221
222func fontCollection() []text.FontFace {
223	regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
224	if err != nil {
225		fmt.Println("Error: Could not read regular font")
226		fmt.Println(err)
227		os.Exit(1)
228	}
229	regularFace, err := opentype.Parse(regularFaceBytes)
230	if err != nil {
231		fmt.Println("Error: Could not parse regular font")
232		fmt.Println(err)
233		os.Exit(1)
234	}
235
236	boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
237	if err != nil {
238		fmt.Println("Error: Could not read bold font")
239		fmt.Println(err)
240		os.Exit(1)
241	}
242	boldFace, err := opentype.Parse(boldFaceBytes)
243	if err != nil {
244		fmt.Println("Error: Could not parse bold font")
245		fmt.Println(err)
246		os.Exit(1)
247	}
248
249	italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
250	if err != nil {
251		fmt.Println("Error: Could not read italic font")
252		fmt.Println(err)
253		os.Exit(1)
254	}
255	italicFace, err := opentype.Parse(italicFaceBytes)
256	if err != nil {
257		fmt.Println("Error: Could not parse italic font")
258		fmt.Println(err)
259		os.Exit(1)
260	}
261
262	boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
263	if err != nil {
264		fmt.Println("Error: Could not read bold italic font")
265		fmt.Println(err)
266		os.Exit(1)
267	}
268	boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
269	if err != nil {
270		fmt.Println("Error: Could not parse bold italic font")
271		fmt.Println(err)
272		os.Exit(1)
273	}
274
275	return []text.FontFace{
276		{
277			Font: text.Font{
278				Typeface: "Primary font",
279				Variant:  "",
280				Style:    text.Regular,
281				Weight:   text.Normal,
282			},
283			Face: regularFace,
284		},
285		{
286			Font: text.Font{
287				Typeface: "Primary font",
288				Variant:  "",
289				Style:    text.Regular,
290				Weight:   text.Bold,
291			},
292			Face: boldFace,
293		},
294		{
295			Font: text.Font{
296				Typeface: "Primary font",
297				Variant:  "",
298				Style:    text.Italic,
299				Weight:   text.Normal,
300			},
301			Face: italicFace,
302		},
303		{
304			Font: text.Font{
305				Typeface: "Primary font",
306				Variant:  "",
307				Style:    text.Italic,
308				Weight:   text.Bold,
309			},
310			Face: boldItalicFace,
311		},
312	}
313}
314
315// Print help message
316func help() {
317	fmt.Println("\nUsage: p2c [options]")
318	fmt.Println("\nOptions:")
319	flag.PrintDefaults()
320	fmt.Print(`
321example: p2c -i input.md -o output.png
322
323p2c is meant for use with Hugo.
324
325It looks at...
326- The Markdown file's frontmatter fields for
327  - title
328  - date
329- The site's config.{toml/yaml/yml} fields for
330  - title (site title)
331- The file's metadata for the last modified date
332
333`)
334}
335
336// Validate flags and return true if the user is manually specifying the title
337// and subtitle
338func validateFlags() bool {
339	ret := false
340	if *flagInput == "" && (*flagTitle == "" || *flagSubtitle == "") {
341		fmt.Println("Error: input file not specified and title or subtitle not specified. Please specify EITHER an input file or both a title AND subtitle.")
342		os.Exit(1)
343	} else if *flagInput != "" && (*flagTitle != "" || *flagSubtitle != "") {
344		fmt.Println("Warning: input file specified and title or subtitle specified. Ignoring title and subtitle flags and continuing with input file")
345	} else if *flagTitle != "" && *flagSubtitle != "" {
346		ret = true
347	} else if *flagTitle == "" && *flagSubtitle != "" {
348		fmt.Println("Error: subtitle specified but title not specified")
349		os.Exit(1)
350	} else if *flagTitle != "" && *flagSubtitle == "" {
351		fmt.Println("Error: title specified but subtitle not specified")
352		os.Exit(1)
353	}
354
355	if *flagOutput == "" {
356		fmt.Println("Error: No output file specified")
357		os.Exit(1)
358	}
359
360	return ret
361}
362
363// Get the post's title, subtitle, and date
364func getPostInfo(input string) (string, string, string) {
365	if _, err := os.Stat(input); os.IsNotExist(err) {
366		fmt.Println("Error: Input file does not exist")
367		fmt.Println(err)
368		os.Exit(1)
369	}
370
371	fileContents, err := os.ReadFile(input)
372	if err != nil {
373		fmt.Println("Error: Could not read input file")
374		fmt.Println(err)
375		os.Exit(1)
376	}
377
378	var fm struct {
379		Title string `yaml:"title"`
380		Date  string `yaml:"date"`
381	}
382
383	content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
384	if err != nil {
385		fmt.Println("Error: Could not parse frontmatter")
386		fmt.Println(err)
387		os.Exit(1)
388	}
389
390	fm.Date = strings.Split(fm.Date, "T")[0]
391
392	return fm.Title, fm.Date, string(content)
393}
394
395// Get the read time of the post
396func getReadTime(content string) string {
397	wordCount := len(strings.Fields(content))
398	if wordCount/200 == 0 || wordCount/200 == 1 {
399		return "1 minute"
400	}
401	return fmt.Sprintf("%d minutes", wordCount/200)
402}
403
404// Get the site's title
405func getSiteTitle() string {
406	validConf := ""
407	confs := []string{"config.toml", "config.yaml", "config.yml"}
408	for _, conf := range confs {
409		if _, err := os.Stat(conf); os.IsNotExist(err) {
410			continue
411		}
412		validConf = conf
413	}
414	if validConf == "" {
415		fmt.Println("Error: No valid config file found, please run this in the root of a Hugo site")
416		os.Exit(1)
417	}
418
419	var t struct {
420		Title string `yaml:"title"`
421	}
422
423	f, err := os.Open(validConf)
424	if err != nil {
425		fmt.Println("Error: Could not open config file")
426		fmt.Println(err)
427		os.Exit(1)
428	}
429	defer f.Close()
430
431	decoder := yaml.NewDecoder(f)
432	err = decoder.Decode(&t)
433	if err != nil {
434		fmt.Println("Error: Could not parse config file")
435		fmt.Println(err)
436		os.Exit(1)
437	}
438
439	return t.Title
440}
441
442// Get the date the post was last edited using the file metadata
443func getEditDate(input string) string {
444	fileInfo, err := os.Stat(input)
445	if err != nil {
446		fmt.Println("Error: Could not get file info")
447		fmt.Println(err)
448		os.Exit(1)
449	}
450
451	return fileInfo.ModTime().Format("2006-01-02")
452}