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