51 | {[...Array(numCircles)].map((_, idx) => (
52 |
setRating(idx)}
56 | onMouseEnter={() => setHoverRating(idx)}
57 | onMouseLeave={() => setHoverRating(null)}
58 | >
59 |
= idx) ||
64 | (hoverRating === null && rating >= idx)
65 | ? circleColor
66 | : "#d1d5db",
67 | }}
68 | />
69 |
70 | ))}
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/Form/IconButton.tsx:
--------------------------------------------------------------------------------
1 | // import { ArrowSmallUpIcon } from "@heroicons/react/20/solid";
2 | import {
3 | ArrowSmallDownIcon,
4 | ArrowSmallUpIcon,
5 | EyeIcon,
6 | EyeSlashIcon,
7 | ListBulletIcon,
8 | TrashIcon,
9 | } from "@heroicons/react/24/outline";
10 | import { IconButton } from "../../Button";
11 |
12 | type MoveIconButtonType = "up" | "down";
13 |
14 | export const ShowIconButton = ({
15 | show,
16 | setShow,
17 | }: {
18 | show: boolean;
19 | setShow: (show: boolean) => void;
20 | }) => {
21 | const tooltipText = show ? "Hide section" : "Show section";
22 | const onClick = () => {
23 | setShow(!show);
24 | };
25 | const Icon = show ? EyeIcon : EyeSlashIcon;
26 |
27 | return (
28 |
29 |
30 | {tooltipText}
31 |
32 | );
33 | };
34 |
35 | export const DeletIconButton = ({
36 | onClick,
37 | tooltipText,
38 | }: {
39 | onClick: () => void;
40 | tooltipText: string;
41 | }) => {
42 | return (
43 |
44 |
45 | {tooltipText}
46 |
47 | );
48 | };
49 |
50 | export const MoveIconButton = ({
51 | type,
52 | size = "medium",
53 | onClick,
54 | }: {
55 | type: MoveIconButtonType;
56 | size?: "small" | "medium";
57 | onClick: (type: MoveIconButtonType) => void;
58 | }) => {
59 | const tooltipText = type === "up" ? "Move up" : "Move down";
60 | const sizeClassName = size === "medium" ? "h-6 w-6" : "h-4 w-4";
61 | const Icon = type === "up" ? ArrowSmallUpIcon : ArrowSmallDownIcon;
62 |
63 | return (
64 |
onClick(type)}
66 | tooltipText={tooltipText}
67 | size={size}
68 | >
69 |
70 | {tooltipText}
71 |
72 | );
73 | };
74 |
75 | export const BulletListIconButton = ({
76 | onClick,
77 | showBulletPoints,
78 | }: {
79 | onClick: (newShowBulletPoints: boolean) => void;
80 | showBulletPoints: boolean;
81 | }) => {
82 | const tooltipText = showBulletPoints
83 | ? "Hide bullet points"
84 | : "Show bullet points";
85 |
86 | return (
87 |
onClick(!showBulletPoints)}
89 | tooltipText={tooltipText}
90 | size="small"
91 | className={showBulletPoints ? "!bg-sky-100" : ""}
92 | >
93 |
99 | {tooltipText}
100 |
101 | );
102 | };
103 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/Form/InputGroup.tsx:
--------------------------------------------------------------------------------
1 | import ContentEditable from "react-contenteditable";
2 |
3 | interface InputProps
{
4 | label: string;
5 | labelClassName?: string;
6 |
7 | name: K;
8 | value?: V;
9 | placeholder: string;
10 | onChange: (name: K, value: V) => void;
11 | }
12 |
13 | export const InputGroupWrapper = ({
14 | label,
15 | className,
16 | children,
17 | }: {
18 | label: string;
19 | className?: string;
20 | children?: React.ReactNode;
21 | }) => (
22 |
23 | {label}
24 | {children}
25 |
26 | );
27 |
28 | export const INPUT_CLASS_NAME =
29 | "mt-1 px-3 py-2 block w-full rounded-md border border-gray-300 text-gray-300 shadow-sm outline-none font-normal text-base";
30 |
31 | export const Input = ({
32 | name,
33 | value = "",
34 | placeholder,
35 | onChange,
36 | label,
37 | labelClassName,
38 | }: InputProps) => (
39 |
40 | onChange(name, e.target.value)}
46 | className={INPUT_CLASS_NAME}
47 | />
48 |
49 | );
50 |
51 | export const BulletListTextArea = ({
52 | label,
53 | labelClassName: wrapperClassName,
54 | name,
55 | value: bulletListStrings = [],
56 | placeholder,
57 | onChange,
58 | showBulletPoints = true,
59 | }: InputProps & {
60 | showBulletPoints?: boolean;
61 | }) => {
62 | const html = getHTMLFromBulletListStrings(bulletListStrings);
63 |
64 | return (
65 |
66 | div]:list-item ${
69 | showBulletPoints ? "pl-7" : "[&>div]:list-['']"
70 | }`}
71 | // placeholder={placeholder}
72 | onChange={(e) => {
73 | if (e.type === "input") {
74 | const { innerText } = e.currentTarget as HTMLDivElement;
75 | const newBulletListStrings =
76 | getBulletListStringsFromInnerText(innerText);
77 | onChange(name, newBulletListStrings);
78 | }
79 | }}
80 | html={html}
81 | />
82 |
83 | );
84 | };
85 |
86 | const NORMALIZED_LINE_BREAK = "\n";
87 | const normalizeLineBreak = (str: string) =>
88 | str.replace(/\r?\n/g, NORMALIZED_LINE_BREAK);
89 | const dedupeLineBreak = (str: string) =>
90 | str.replace(/\n\n/g, NORMALIZED_LINE_BREAK);
91 | const getStringsByLineBreak = (str: string) => str.split(NORMALIZED_LINE_BREAK);
92 |
93 | const getBulletListStringsFromInnerText = (innerText: string) => {
94 | const innerTextWithNormalizedLineBreak = normalizeLineBreak(innerText);
95 |
96 | let newInnerText = dedupeLineBreak(innerTextWithNormalizedLineBreak);
97 |
98 | if (newInnerText === NORMALIZED_LINE_BREAK) {
99 | newInnerText = "";
100 | }
101 | return getStringsByLineBreak(newInnerText);
102 | };
103 |
104 | const getHTMLFromBulletListStrings = (bulletListStrings: string[]) => {
105 | if (bulletListStrings.length === 0) {
106 | return "
";
107 | }
108 |
109 | return bulletListStrings.map((text) => `${text}
`).join("");
110 | };
111 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/Form/index.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks";
2 | import {
3 | ShowForm,
4 | changeFormHeading,
5 | changeFormOrder,
6 | changeShowForm,
7 | selectHeadingByForm,
8 | selectIsFirstForm,
9 | selectIsLastForm,
10 | selectShowByForm,
11 | } from "@/app/lib/redux/settingsSlice";
12 | import {
13 | AcademicCapIcon,
14 | BuildingOfficeIcon,
15 | LightBulbIcon,
16 | WrenchIcon,
17 | } from "@heroicons/react/24/outline";
18 | import { Input } from "./InputGroup";
19 | import { DeletIconButton, MoveIconButton, ShowIconButton } from "./IconButton";
20 | import { ExpanderWithHeightTransition } from "../../ExpanderWithHeightTransition";
21 | import {
22 | addSectionInForm,
23 | deleteSectionInFormByIdx,
24 | moveSectionInForm,
25 | } from "@/app/lib/redux/resumeSlice";
26 | import { PlusSmallIcon } from "@heroicons/react/24/outline";
27 |
28 | const FORM_TO_ICON: { [section in ShowForm]: typeof BuildingOfficeIcon } = {
29 | workExperiences: BuildingOfficeIcon,
30 | educations: AcademicCapIcon,
31 | projects: LightBulbIcon,
32 | skills: WrenchIcon,
33 | custom: WrenchIcon,
34 | };
35 |
36 | export const BaseForm = ({
37 | children,
38 | className,
39 | }: {
40 | children: React.ReactNode;
41 | className?: string;
42 | }) => (
43 |
48 | );
49 |
50 | export const Form = ({
51 | form,
52 | addButtonText,
53 | children,
54 | }: {
55 | form: ShowForm;
56 | addButtonText?: string;
57 | children: React.ReactNode;
58 | }) => {
59 | const showForm = useAppSelector(selectShowByForm(form));
60 | const heading = useAppSelector(selectHeadingByForm(form));
61 |
62 | const dispatch = useAppDispatch();
63 |
64 | const setShowForm = (showForm: boolean) => {
65 | dispatch(changeShowForm({ field: form, value: showForm }));
66 | };
67 |
68 | const setHeading = (heading: string) => {
69 | dispatch(changeFormHeading({ field: form, value: heading }));
70 | };
71 |
72 | const isFirstForm = useAppSelector(selectIsFirstForm(form));
73 | const isLastForm = useAppSelector(selectIsLastForm(form));
74 |
75 | const handleMoveclick = (type: "up" | "down") => {
76 | dispatch(changeFormOrder({ form, type }));
77 | };
78 |
79 | const Icon = FORM_TO_ICON[form];
80 |
81 | return (
82 |
87 |
88 |
89 |
90 | setHeading(e.target.value)}
95 | />
96 |
97 |
98 | {!isFirstForm && (
99 |
100 | )}
101 | {!isLastForm && (
102 |
103 | )}
104 |
105 |
106 |
107 |
108 | {children}
109 |
110 | {showForm && addButtonText && (
111 |
112 |
{
115 | dispatch(addSectionInForm({ form }));
116 | }}
117 | className="flex items-center rounded-md bg-white py-2 pl-3 pr-4 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
118 | >
119 |
120 | {addButtonText}
121 |
122 |
123 | )}
124 |
125 | );
126 | };
127 |
128 | export const FormSection = ({
129 | form,
130 | idx,
131 | showMoveUp,
132 | showMoveDown,
133 | showDelete,
134 | deleteButtonTooltipText,
135 | children,
136 | }: {
137 | form: ShowForm;
138 | idx: number;
139 | showMoveUp: boolean;
140 | showMoveDown: boolean;
141 | showDelete: boolean;
142 | deleteButtonTooltipText: string;
143 | children: React.ReactNode;
144 | }) => {
145 | const dispatch = useAppDispatch();
146 |
147 | const handleDeleteClick = () => {
148 | dispatch(deleteSectionInFormByIdx({ form, idx }));
149 | };
150 |
151 | const handleMoveClick = (direction: "up" | "down") => {
152 | dispatch(moveSectionInForm({ form, direction, idx }));
153 | };
154 |
155 | return (
156 | <>
157 | {idx !== 0 && (
158 |
159 | )}
160 |
161 | {children}
162 |
163 |
168 | handleMoveClick("up")}
172 | />
173 |
174 |
179 | handleMoveClick("down")}
183 | />
184 |
185 |
190 |
194 |
195 |
196 |
197 | >
198 | );
199 | };
200 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/ProfileForm.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks";
2 | import { changeProfile, selectProfile } from "@/app/lib/redux/resumeSlice";
3 | import { BaseForm } from "./Form";
4 | import { Input } from "./Form/InputGroup";
5 | import { ResumeProfile } from "@/app/lib/redux/types";
6 |
7 | export const ProfileForm = () => {
8 | const profile = useAppSelector(selectProfile);
9 | const dispatch = useAppDispatch();
10 |
11 | const { name, email, phone, url, summary, location } = profile;
12 |
13 | const handleProfileChange = (field: keyof ResumeProfile, value: string) => {
14 | dispatch(changeProfile({ field, value }));
15 | };
16 |
17 | return (
18 |
19 |
20 |
28 |
36 |
44 |
52 |
60 |
68 |
69 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/ProjectsForm.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks";
2 | import {
3 | changeEducations,
4 | selectEducations,
5 | selectProjects,
6 | changeProjects,
7 | } from "@/app/lib/redux/resumeSlice";
8 | import {
9 | changeShowBulletPoints,
10 | selectShowBulletPoints,
11 | } from "@/app/lib/redux/settingsSlice";
12 | import { Form, FormSection } from "./Form";
13 | import { CreateHandleChangeArgsWithDescriptions } from "./types";
14 | import { ResumeEducation, ResumeProject } from "@/app/lib/redux/types";
15 | import { BulletListTextArea, Input } from "./Form/InputGroup";
16 | import { BulletListIconButton } from "./Form/IconButton";
17 |
18 | export const ProjectsForm = () => {
19 | const projects = useAppSelector(selectProjects);
20 | const dispatch = useAppDispatch();
21 | const showDelete = projects.length > 1;
22 |
23 | return (
24 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/SkillsForm.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks";
2 | import { changeSkills, selectSkills } from "@/app/lib/redux/resumeSlice";
3 | import {
4 | changeShowBulletPoints,
5 | selectShowBulletPoints,
6 | selectThemeColor,
7 | } from "@/app/lib/redux/settingsSlice";
8 | import { Form } from "./Form";
9 | import { BulletListTextArea, InputGroupWrapper } from "./Form/InputGroup";
10 | import { BulletListIconButton } from "./Form/IconButton";
11 | import { FeaturedSkillInput } from "./Form/FeaturedSkillInput";
12 |
13 | export const SkillsForm = () => {
14 | const skills = useAppSelector(selectSkills);
15 | const dispatch = useAppDispatch();
16 | const { featuredSkills, descriptions } = skills;
17 | const form = "skills";
18 | const showBulletPoints = useAppSelector(selectShowBulletPoints(form));
19 | const themeColor = useAppSelector(selectThemeColor) || "#38bdf8";
20 |
21 | const handleSkillsChange = (field: "descriptions", value: string[]) => {
22 | dispatch(changeSkills({ field, value }));
23 | };
24 |
25 | const handleFeaturedSkillsChange = (
26 | idx: number,
27 | skill: string,
28 | rating: number
29 | ) => {
30 | dispatch(changeSkills({ field: "featuredSkills", idx, skill, rating }));
31 | };
32 |
33 | const handleShowBulletPoints = (value: boolean) => {
34 | dispatch(changeShowBulletPoints({ field: form, value }));
35 | };
36 |
37 | return (
38 |
83 | );
84 | };
85 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/ThemeForm/InlineInput.tsx:
--------------------------------------------------------------------------------
1 | interface InputProps {
2 | label: string;
3 | labelClassName?: string;
4 | name: K;
5 | value?: V;
6 | placeholder: string;
7 | inputStyle?: React.CSSProperties;
8 | onChange: (name: K, value: V) => void;
9 | }
10 |
11 | export const InlineInput = ({
12 | label,
13 | labelClassName,
14 | name,
15 | value = "",
16 | placeholder,
17 | inputStyle = {},
18 | onChange,
19 | }: InputProps) => {
20 | return (
21 |
24 | {label}
25 | onChange(name, e.target.value)}
31 | className="w-[5rem] border-b border-gray-300 text-center font-semibold leading-3 outline-none"
32 | style={inputStyle}
33 | />
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/ThemeForm/Selection.tsx:
--------------------------------------------------------------------------------
1 | import { GeneralSetting } from "@/app/lib/redux/settingsSlice";
2 | import {
3 | FONT_FAMILY_TO_DISPLAY_NAME,
4 | FONT_FAMILY_TO_STANDARD_SIZE_IN_PT,
5 | getAllFontFamiliesToLoad,
6 | } from "../../fonts/constants";
7 | import { PX_PER_PT } from "@/app/lib/constants";
8 | import dynamic from "next/dynamic";
9 |
10 | const SelectionComponent = ({
11 | selectedColor,
12 | isSelected,
13 | style = {},
14 | onClick,
15 | children,
16 | }: {
17 | selectedColor: string;
18 | isSelected: boolean;
19 | style?: React.CSSProperties;
20 | onClick: () => void;
21 | children: React.ReactNode;
22 | }) => {
23 | const selectedStyle = {
24 | color: "white",
25 | backgroundColor: selectedColor,
26 | borderColor: selectedColor,
27 | ...style,
28 | };
29 |
30 | return (
31 | {
36 | if (["Enter", " "].includes(e.key)) onClick();
37 | }}
38 | tabIndex={0}
39 | >
40 | {children}
41 |
42 | );
43 | };
44 |
45 | const SelectionsWrapper = ({ children }: { children: React.ReactNode }) => {
46 | return {children}
;
47 | };
48 |
49 | const FontFamilySelections = ({
50 | selectedFontFamily,
51 | themeColor,
52 | handleSettingsChange,
53 | }: {
54 | selectedFontFamily: string;
55 | themeColor: string;
56 | handleSettingsChange: (field: GeneralSetting, value: string) => void;
57 | }) => {
58 | const allFontFamilies = getAllFontFamiliesToLoad();
59 | return (
60 |
61 | {allFontFamilies.map((fontFamily, idx) => {
62 | const isSelected = selectedFontFamily === fontFamily;
63 | const standardSizePt = FONT_FAMILY_TO_STANDARD_SIZE_IN_PT[fontFamily];
64 |
65 | return (
66 | handleSettingsChange("fontFamily", fontFamily)}
75 | >
76 | {FONT_FAMILY_TO_DISPLAY_NAME[fontFamily]}
77 |
78 | );
79 | })}
80 |
81 | );
82 | };
83 |
84 | export const FontFamilySelectionCSR = dynamic(
85 | () => Promise.resolve(FontFamilySelections),
86 | {
87 | ssr: false,
88 | }
89 | );
90 |
91 | export const FontSizeSelections = ({
92 | fontFamily,
93 | themeColor,
94 | selectedFontSize,
95 | handleSettingsChange,
96 | }: {
97 | fontFamily: string;
98 | themeColor: string;
99 | selectedFontSize: string;
100 | handleSettingsChange: (field: GeneralSetting, value: string) => void;
101 | }) => {
102 | const standardSizePt = FONT_FAMILY_TO_STANDARD_SIZE_IN_PT[fontFamily];
103 | const compactSizePt = standardSizePt - 1;
104 |
105 | return (
106 |
107 | {["Compact", "Standard", "Large"].map((type, idx) => {
108 | const fontSizePt = String(compactSizePt + idx);
109 | const isSelected = fontSizePt === selectedFontSize;
110 |
111 | return (
112 | handleSettingsChange("fontSize", fontSizePt)}
121 | >
122 | {type}
123 |
124 | );
125 | })}
126 |
127 | );
128 | };
129 |
130 | export const DocumentSizeSelections = ({
131 | themeColor,
132 | selectedDocumentSize,
133 | handleSettingsChange,
134 | }: {
135 | themeColor: string;
136 | selectedDocumentSize: string;
137 | handleSettingsChange: (field: GeneralSetting, value: string) => void;
138 | }) => {
139 | return (
140 |
141 | {["Letter", "A4"].map((type, idx) => {
142 | return (
143 | handleSettingsChange("documentSize", type)}
148 | >
149 |
150 |
{type}
151 |
152 | {type === "Letter" ? "(US, Canada)" : "(India,Other Countries)"}
153 |
154 |
155 |
156 | );
157 | })}
158 |
159 | );
160 | };
161 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/ThemeForm/constants.ts:
--------------------------------------------------------------------------------
1 | export const THEME_COLORS = [
2 | "#f87171",
3 | "#ef4444",
4 | "#fb923c",
5 | "#f97316",
6 | "#fbbf24",
7 | "#f59e0b",
8 | "#22c55e",
9 | "#15803d",
10 | "#38bdf8",
11 | "#0ea5e9",
12 | "#818cf8",
13 | "#6366f1",
14 | ];
15 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/ThemeForm/index.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks";
2 | import {
3 | DEFAULT_THEME_COLOR,
4 | GeneralSetting,
5 | changeSettings,
6 | selectSettings,
7 | } from "@/app/lib/redux/settingsSlice";
8 | import { BaseForm } from "../Form";
9 | import { Cog6ToothIcon } from "@heroicons/react/24/outline";
10 | import { InlineInput } from "./InlineInput";
11 | import { THEME_COLORS } from "./constants";
12 | import { InputGroupWrapper } from "../Form/InputGroup";
13 | import {
14 | DocumentSizeSelections,
15 | FontFamilySelectionCSR,
16 | FontSizeSelections,
17 | } from "./Selection";
18 | import { FontFamily } from "../../fonts/constants";
19 |
20 | export const ThemeForm = () => {
21 | const settings = useAppSelector(selectSettings);
22 | const { fontSize, fontFamily, documentSize } = settings;
23 | const themeColor = settings.themeColor || DEFAULT_THEME_COLOR;
24 | const dispatch = useAppDispatch();
25 |
26 | const handleSettingsChange = (field: GeneralSetting, value: string) => {
27 | dispatch(changeSettings({ field, value }));
28 | };
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | Resume Setting
37 |
38 |
39 |
40 |
48 |
49 | {THEME_COLORS.map((color, idx) => (
50 |
handleSettingsChange("themeColor", color)}
55 | onKeyDown={(e) => {
56 | if (["Enter", " "].includes(e.key)) {
57 | handleSettingsChange("themeColor", color);
58 | }
59 | }}
60 | tabIndex={0}
61 | >
62 | {settings.themeColor === color ? "$" : ""}
63 |
64 | ))}
65 |
66 |
67 |
68 |
69 |
74 |
75 |
76 |
83 |
89 |
90 |
91 |
92 |
97 |
98 |
99 |
100 | );
101 | };
102 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/WorkExperiencesForm.tsx:
--------------------------------------------------------------------------------
1 | import { useAppDispatch, useAppSelector } from "@/app/lib/redux/hooks";
2 | import {
3 | changeWorkExperience,
4 | selectWorkExperiences,
5 | } from "@/app/lib/redux/resumeSlice";
6 | import { Form, FormSection } from "./Form";
7 | import { CreateHandleChangeArgsWithDescriptions } from "./types";
8 | import { ResumeWorkExperience } from "@/app/lib/redux/types";
9 | import { BulletListTextArea, Input } from "./Form/InputGroup";
10 |
11 | export const WorkExperiencesForm = () => {
12 | const workExperiences = useAppSelector(selectWorkExperiences);
13 | const dispatch = useAppDispatch();
14 |
15 | const showDelete = workExperiences.length > 1;
16 |
17 | return (
18 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/index.tsx:
--------------------------------------------------------------------------------
1 | import { cx } from "@/app/lib/cx";
2 | import {
3 | useAppSelector,
4 | useSaveStateToLocalStorageOnChange,
5 | useSetInitialStore,
6 | } from "@/app/lib/redux/hooks";
7 | import { useState } from "react";
8 | import { ProfileForm } from "./ProfileForm";
9 | import { ShowForm, selectFormsOrder } from "@/app/lib/redux/settingsSlice";
10 | import { WorkExperiencesForm } from "./WorkExperiencesForm";
11 | import { EducationsForm } from "./EducationsForm";
12 | import { ProjectsForm } from "./ProjectsForm";
13 | import { SkillsForm } from "./SkillsForm";
14 | import { CustomForm } from "./CustomForm";
15 | import { ThemeForm } from "./ThemeForm";
16 |
17 | const formTypeToComponent: { [type in ShowForm]: () => JSX.Element } = {
18 | workExperiences: WorkExperiencesForm,
19 | educations: EducationsForm,
20 | projects: ProjectsForm,
21 | skills: SkillsForm,
22 | custom: CustomForm,
23 | };
24 |
25 | export const ResumeForm = () => {
26 | useSetInitialStore();
27 | useSaveStateToLocalStorageOnChange();
28 |
29 | const [isHover, setIsHover] = useState(false);
30 |
31 | const formsOrder = useAppSelector(selectFormsOrder);
32 |
33 | return (
34 | setIsHover(true)}
40 | onMouseLeave={() => setIsHover(false)}
41 | >
42 |
43 |
44 | {formsOrder.map((form) => {
45 | const Component = formTypeToComponent[form];
46 | return ;
47 | })}
48 |
49 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/app/components/ResumeForm/types.ts:
--------------------------------------------------------------------------------
1 | export type CreateHandleChangeArgsWithDescriptions =
2 | | [field: Exclude, value: string]
3 | | [field: "descriptions", value: string[]];
4 |
--------------------------------------------------------------------------------
/app/components/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useRef, useState } from "react";
4 | import { createPortal } from "react-dom";
5 |
6 | export const ToolTip = ({
7 | text,
8 | children,
9 | }: {
10 | text: string;
11 | children: React.ReactNode;
12 | }) => {
13 | const spanRef = useRef(null);
14 | const tooltipRef = useRef(null);
15 |
16 | const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
17 |
18 | const [show, setShow] = useState(false);
19 |
20 | const showTooltip = () => setShow(true);
21 | const hideTooltip = () => setShow(false);
22 |
23 | useEffect(() => {
24 | const span = spanRef.current;
25 | const tooltip = tooltipRef.current;
26 | if (span && tooltip) {
27 | const rect = span.getBoundingClientRect();
28 | const TOP_OFFSET = 6;
29 | const newTop = rect.top + rect.height + TOP_OFFSET;
30 | const newLeft = rect.left - tooltip.offsetWidth / 2 + rect.width / 2;
31 | setTooltipPos({
32 | top: newTop,
33 | left: newLeft,
34 | });
35 | }
36 | }, [show]);
37 |
38 | return (
39 |
47 | {children}
48 | {show &&
49 | createPortal(
50 |
59 | {text}
60 |
,
61 | document.body
62 | )}
63 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/app/components/TopNavBar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { usePathname } from "next/navigation";
4 | import { cx } from "../lib/cx";
5 | import Link from "next/link";
6 | import Image from "next/image";
7 |
8 | export const TopNavBar = () => {
9 | const pathname = usePathname();
10 | const isHomePage = pathname === "/";
11 |
12 | return (
13 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/app/components/documentation/Heading.tsx:
--------------------------------------------------------------------------------
1 | import { cx } from "@/app/lib/cx";
2 |
3 | const HEADING_CLASSNAMES = {
4 | 1: "text-2xl font-bold",
5 | 2: "text-xl font-bold",
6 | 3: "text-lg font-semibold",
7 | };
8 |
9 | export const Heading = ({
10 | level = 1,
11 | children,
12 | className = "",
13 | }: {
14 | level?: 1 | 2 | 3;
15 | smallMarginTop?: boolean;
16 | children: React.ReactNode;
17 | className?: string;
18 | }) => {
19 | const Component = `h${level}` as const;
20 |
21 | return (
22 |
29 | {children}
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/app/components/documentation/Paragraph.tsx:
--------------------------------------------------------------------------------
1 | import { cx } from "@/app/lib/cx";
2 |
3 | export const Paragraph = ({
4 | smallMarginTop = false,
5 | children,
6 | className = "",
7 | }: {
8 | smallMarginTop?: boolean;
9 | children: React.ReactNode;
10 | className?: string;
11 | }) => {
12 | return (
13 |
20 | {children}
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/app/components/fonts/constants.ts:
--------------------------------------------------------------------------------
1 | const SANS_SERIF_FONT_FAMILIES = [
2 | "Roboto",
3 | "Lato",
4 | "OpenSans",
5 | "Montserrat",
6 | "Raleway",
7 | ];
8 |
9 | const SERIF_FONT_FAMILIES = [
10 | "Lora",
11 | "RobotoSlab",
12 | "Merriweather",
13 | "Caladea",
14 | "PlayfairDisplay",
15 | ];
16 |
17 | export const FONT_FAMILIES = [
18 | ...SANS_SERIF_FONT_FAMILIES,
19 | ...SERIF_FONT_FAMILIES,
20 | ];
21 |
22 | export type FontFamily = (typeof FONT_FAMILIES)[number];
23 |
24 | export const FONT_FAMILY_TO_STANDARD_SIZE_IN_PT: Record = {
25 | Roboto: 11,
26 | Lato: 11,
27 | Montserrat: 10,
28 | OpenSans: 10,
29 | Raleway: 10,
30 |
31 | Caladea: 11,
32 | Lora: 11,
33 | RobotoSlab: 10,
34 | PlayfairDisplay: 10,
35 | Merriweather: 10,
36 | };
37 |
38 | export const FONT_FAMILY_TO_DISPLAY_NAME: Record = {
39 | Roboto: "Roboto",
40 | Lato: "Lato",
41 | Montserrat: "Montserrat",
42 | OpenSans: "Open Sans",
43 | Raleway: "Raleway",
44 |
45 | Caladea: "Caladea",
46 | Lora: "Lora",
47 | RobotoSlab: "Roboto Slab",
48 | PlayfairDisplay: "Playfair Display",
49 | Merriweather: "Merriweather",
50 | };
51 |
52 | export const getAllFontFamiliesToLoad = () => {
53 | return [...FONT_FAMILIES];
54 | };
55 |
--------------------------------------------------------------------------------
/app/components/fonts/hooks.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { FONT_FAMILIES, getAllFontFamiliesToLoad } from "./constants";
3 | import { Font } from "@react-pdf/renderer";
4 |
5 | export const useRegisterReactPDFFont = () => {
6 | useEffect(() => {
7 | const allFontFamilies = getAllFontFamiliesToLoad();
8 | allFontFamilies.forEach((fontFamily) => {
9 | Font.register({
10 | family: fontFamily,
11 | fonts: [
12 | {
13 | src: `fonts/${fontFamily}-Regular.ttf`,
14 | },
15 | {
16 | src: `fonts/${fontFamily}-Bold.ttf`,
17 | fontWeight: "bold",
18 | },
19 | ],
20 | });
21 | });
22 | }, []);
23 | };
24 |
25 | export const useRegisterReactPDFHypenationCallback = (fontFamily: string) => {
26 | useEffect(() => {
27 | if (FONT_FAMILIES.includes(fontFamily as any)) {
28 | Font.registerHyphenationCallback((word) => [word]);
29 | } else {
30 | Font.registerHyphenationCallback((word) =>
31 | word
32 | .split("")
33 | .map((char) => [char, ""])
34 | .flat()
35 | );
36 | }
37 | }, [fontFamily]);
38 | };
39 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import url("/fonts/fonts.css");
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
5 |
6 | @layer components {
7 | .btn-primary {
8 | @apply bg-primary outline-theme-purple inline-block rounded-full px-6 py-2 font-semibold shadow-sm;
9 | }
10 | .text-primary {
11 | @apply bg-gradient-to-r from-[color:var(--theme-purple)] to-[color:var(--theme-blue)] bg-clip-text text-transparent !important;
12 | }
13 | .bg-primary {
14 | @apply bg-gradient-to-r from-[color:var(--theme-purple)] to-[color:var(--theme-blue)] text-white;
15 | }
16 | .outline-theme-purple {
17 | @apply hover:opacity-80 hover:outline-[color:var(--theme-purple)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--theme-purple)];
18 | }
19 | .outline-theme-blue {
20 | @apply hover:opacity-80 hover:outline-[color:var(--theme-blue)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--theme-blue)];
21 | }
22 | .within-outline-theme-purple {
23 | @apply focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[color:var(--theme-purple)] hover:opacity-80 hover:outline-[color:var(--theme-purple)];
24 | }
25 | }
26 |
27 | :root {
28 | --theme-purple: #5d52d9;
29 | --theme-blue: #4fc5eb;
30 |
31 | --top-nav-bar-height: 3.5rem;
32 | --resume-control-bar-height: 3rem;
33 | --resume-padding: 1.5rem;
34 | }
35 |
--------------------------------------------------------------------------------
/app/home/AutoTypingResume.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useRef, useState } from "react";
4 | import { deepClone } from "../lib/parse-resume-from-pdf/deep-clone";
5 | import { initialResumeState } from "../lib/redux/resumeSlice";
6 | import { makeObjectCharIterator } from "../lib/make-object-char-iterator";
7 | import { END_HOME_RESUME, START_HOME_RESUME } from "./constants";
8 | import { ResumeIFrameCSR } from "../components/Resume/ResumeIFrame";
9 | import { ResumePDF } from "../components/Resume/ResumePDF";
10 | import { initialSettings } from "../lib/redux/settingsSlice";
11 |
12 | export const AutoTypingResume = () => {
13 | const [resume, setResume] = useState(deepClone(initialResumeState));
14 | const resumeCharIterator = useRef(
15 | makeObjectCharIterator(START_HOME_RESUME, END_HOME_RESUME)
16 | );
17 |
18 | const hasSetEndResume = useRef(false);
19 |
20 | useEffect(() => {
21 | const intervalId = setInterval(() => {
22 | let next = resumeCharIterator.current.next();
23 | for (let i = 0; i < 9; i++) {
24 | next = resumeCharIterator.current.next();
25 | }
26 | if (!next.done) {
27 | setResume(next.value);
28 | } else {
29 | if (!hasSetEndResume.current) {
30 | setResume(END_HOME_RESUME);
31 | hasSetEndResume.current = true;
32 | }
33 | }
34 | }, 50);
35 |
36 | return () => clearInterval(intervalId);
37 | });
38 |
39 | useEffect(() => {
40 | const intervalId = setInterval(() => {
41 | resumeCharIterator.current = makeObjectCharIterator(
42 | START_HOME_RESUME,
43 | END_HOME_RESUME
44 | );
45 | hasSetEndResume.current = false;
46 | }, 60 * 1000);
47 | return () => clearInterval(intervalId);
48 | }, []);
49 |
50 | return (
51 | <>
52 |
53 |
69 |
70 | >
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/app/home/Hero.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { FlexboxSpacer } from "../components/FlexboxSpacer";
3 | import { AutoTypingResume } from "./AutoTypingResume";
4 |
5 | export const Hero = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | Create a professional
12 |
13 | resume easily
14 |
15 |
16 | With this powerful resume builder
17 |
18 |
19 | Create Resume
20 |
21 |
No sign up required
22 |
23 | Already have a resume? Test its ATS readability with the{" "}
24 |
25 | resume parser
26 |
27 |
28 |
29 | {" "}
30 |
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/app/home/Steps.tsx:
--------------------------------------------------------------------------------
1 | const STEPS = [
2 | { title: "Add a resume pdf", text: "or create from scratch" },
3 | { title: "Preview design", text: "and make edits" },
4 | { title: "Download new resume", text: "and apply with confidence" },
5 | ];
6 |
7 | export const Steps = () => {
8 | return (
9 |
10 | 3 Simple Steps
11 |
12 |
13 | {STEPS.map(({ title, text }, idx) => (
14 |
15 |
16 |
17 |
18 |
19 | {idx + 1}
20 |
21 |
22 |
23 | {title}
24 |
25 |
{text}
26 |
27 | ))}
28 |
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/app/home/constants.ts:
--------------------------------------------------------------------------------
1 | import { deepClone } from "../lib/parse-resume-from-pdf/deep-clone";
2 | import {
3 | initialEducation,
4 | initialProfile,
5 | initialProject,
6 | initialWorkExperience,
7 | } from "../lib/redux/resumeSlice";
8 | import { Resume } from "../lib/redux/types";
9 |
10 | export const END_HOME_RESUME: Resume = {
11 | profile: {
12 | name: "Kuluru Vineeth",
13 | summary:
14 | "Software engineer obsessed with building exceptional products that people love",
15 | email: "test@gmail.com",
16 | phone: "123-456-7890",
17 | location: "HYD,IND",
18 | url: "linkedin.com/in/yourusername",
19 | },
20 | workExperiences: [
21 | {
22 | company: "ABC Company",
23 | jobTitle: "Software Engineer",
24 | date: "May 2023 - Present",
25 | descriptions: [
26 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
27 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
28 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
29 | ],
30 | },
31 | {
32 | company: "DEF Organization",
33 | jobTitle: "Software Engineer",
34 | date: "May 2022 - May 2023",
35 | descriptions: [
36 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
37 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
38 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
39 | ],
40 | },
41 | {
42 | company: "XYZ Company",
43 | jobTitle: "Software Engineer",
44 | date: "May 2021 - May 2022",
45 | descriptions: [
46 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
47 | ],
48 | },
49 | ],
50 | educations: [
51 | {
52 | school: "XYZ University",
53 | degree: "Bachelor of Science in Computer Science",
54 | date: "Sep 2018 - Aug 2022",
55 | gpa: "8.55",
56 | descriptions: [
57 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
58 | ],
59 | },
60 | ],
61 | projects: [
62 | {
63 | project: "Project1",
64 | date: "Fall 2021",
65 | descriptions: [
66 | "Contributed and Collaborated with cross functional teams to build the scalable product consumned by larger audiences",
67 | ],
68 | },
69 | ],
70 | skills: {
71 | featuredSkills: [
72 | { skill: "Python", rating: 3 },
73 | { skill: "TypeScript", rating: 3 },
74 | { skill: "React", rating: 3 },
75 | ],
76 | descriptions: [
77 | "Tech: React Hooks, GraphQL, Node.js, SQL, Postgres, NoSql, Redis, REST API, Git",
78 | "Soft: Teamwork, Creative Problem Solving, Communication, Learning Mindset, Agile",
79 | ],
80 | },
81 | custom: {
82 | descriptions: [],
83 | },
84 | };
85 |
86 | export const START_HOME_RESUME: Resume = {
87 | profile: deepClone(initialProfile),
88 | educations: [deepClone(initialEducation)],
89 | projects: [deepClone(initialProject)],
90 | custom: {
91 | descriptions: [],
92 | },
93 | workExperiences: END_HOME_RESUME.workExperiences.map(() =>
94 | deepClone(initialWorkExperience)
95 | ),
96 | skills: {
97 | featuredSkills: END_HOME_RESUME.skills.featuredSkills.map((item) => ({
98 | skill: "",
99 | rating: item.rating,
100 | })),
101 | descriptions: [],
102 | },
103 | };
104 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import { TopNavBar } from "./components/TopNavBar";
5 |
6 | const inter = Inter({ subsets: ["latin"] });
7 |
8 | export const metadata: Metadata = {
9 | title: "Create Next App",
10 | description: "Generated by create next app",
11 | };
12 |
13 | export default function RootLayout({
14 | children,
15 | }: {
16 | children: React.ReactNode;
17 | }) {
18 | return (
19 |
20 |
21 |
22 | {children}
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/app/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const PX_PER_PT = 4 / 3;
2 |
3 | export const A4_WIDTH_PT = 595;
4 | const A4_HEIGHT_PT = 842;
5 | export const A4_WIDTH_PX = A4_WIDTH_PT * PX_PER_PT;
6 | export const A4_HEIGHT_PX = A4_HEIGHT_PT * PX_PER_PT;
7 |
8 | export const LETTER_WIDTH_PT = 612;
9 | export const LETTER_HEIGHT_PT = 792;
10 |
11 | export const LETTER_WIDTH_PX = LETTER_WIDTH_PT * PX_PER_PT;
12 | export const LETTER_HEIGHT_PX = LETTER_HEIGHT_PT * PX_PER_PT;
13 |
--------------------------------------------------------------------------------
/app/lib/cx.ts:
--------------------------------------------------------------------------------
1 | export const cx = (...classes: Array) => {
2 | const newClasses = [];
3 | for (const c of classes) {
4 | if (typeof c === "string") {
5 | newClasses.push(c.trim());
6 | }
7 | }
8 |
9 | return newClasses.join(" ");
10 | };
11 |
--------------------------------------------------------------------------------
/app/lib/deep-merge.ts:
--------------------------------------------------------------------------------
1 | type Object = { [key: string]: any };
2 |
3 | const isObject = (item: any): item is Object => {
4 | return item && typeof item === "object" && !Array.isArray(item);
5 | };
6 |
7 | export const deepMerge = (target: Object, source: Object, level = 0) => {
8 | const copyTarget = level === 0 ? structuredClone(target) : target;
9 |
10 | for (const key in source) {
11 | const sourceValue = source[key];
12 |
13 | if (!isObject(sourceValue)) {
14 | copyTarget[key] = sourceValue;
15 | } else {
16 | if (!isObject(copyTarget[key])) {
17 | copyTarget[key] = {};
18 | }
19 | deepMerge(copyTarget[key], sourceValue, level + 1);
20 | }
21 | }
22 | return copyTarget;
23 | };
24 |
--------------------------------------------------------------------------------
/app/lib/make-object-char-iterator.ts:
--------------------------------------------------------------------------------
1 | import { deepClone } from "./parse-resume-from-pdf/deep-clone";
2 |
3 | type Object = { [key: string]: any };
4 |
5 | /**
6 | *
7 | * start = {a: ""}
8 | * end = {a: "abc"}
9 | * const iterator = func(start,end)
10 | * iterator.next().value = {a: "a"}
11 | * {a: "ab"}
12 | * {a: "abc"}
13 | * @param start
14 | * @param end
15 | * @param level
16 | */
17 | export function* makeObjectCharIterator(
18 | start: T,
19 | end: T,
20 | level = 0
21 | ) {
22 | const object: Object = level === 0 ? deepClone(start) : start;
23 |
24 | for (const [key, endValue] of Object.entries(end)) {
25 | if (typeof endValue === "object") {
26 | const recursiveIterator = makeObjectCharIterator(
27 | object[key],
28 | endValue,
29 | level + 1
30 | );
31 | while (true) {
32 | const next = recursiveIterator.next();
33 | if (next.done) {
34 | break;
35 | }
36 |
37 | yield deepClone(object) as T;
38 | }
39 | } else {
40 | for (let i = 0; i <= endValue.length; i++) {
41 | object[key] = endValue.slice(0, i);
42 | yield deepClone(object) as T;
43 | }
44 | }
45 | }
46 | }
47 |
48 | export const countObjectChar = (object: Object) => {
49 | let count = 0;
50 | for (const value of Object.values(object)) {
51 | if (typeof value === "object") {
52 | count += countObjectChar(value);
53 | } else if (typeof value === "string") {
54 | count += value.length;
55 | }
56 | }
57 | return count;
58 | };
59 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/deep-clone.ts:
--------------------------------------------------------------------------------
1 | export const deepClone = (object: T) =>
2 | JSON.parse(JSON.stringify(object)) as T;
3 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-education.ts:
--------------------------------------------------------------------------------
1 | import { ResumeEducation } from "../../redux/types";
2 | import { hasLetter } from "../group-lines-into-sections";
3 | import { FeatureSet, ResumeSectionToLines, TextItem } from "../types";
4 | import { hasComma, hasNumber } from "./extract-profile";
5 | import {
6 | getBulletPointsFromLines,
7 | getDescriptionsLineIdx,
8 | } from "./lib/bullet-points";
9 | import { DATE_FEATURE_SETS } from "./lib/common-features";
10 | import { getTextWithHighestFeatureScore } from "./lib/feature-scoring-system";
11 | import { getSectionLinesByKeywords } from "./lib/get-section-lines";
12 | import { divideSectionIntoSubsections } from "./lib/subsections";
13 |
14 | const SCHOOLS = ["College", "University", "Institute", "School", "Academy"];
15 | const hasSchool = (item: TextItem) =>
16 | SCHOOLS.some((school) => item.text.includes(school));
17 |
18 | const DEGREES = ["Bachelor", "Master", "PhD", "Ph."];
19 | const hasDegree = (item: TextItem) =>
20 | DEGREES.some((degree) => item.text.includes(degree)) ||
21 | /[ABM][A-Z\.]/.test(item.text);
22 |
23 | const matchGPA = (item: TextItem) => item.text.match(/[0-4]\.\d{1,2}/);
24 |
25 | const matchGrade = (item: TextItem) => {
26 | const grade = parseFloat(item.text);
27 | if (Number.isFinite(grade) && grade <= 110) {
28 | return [String(grade)] as RegExpMatchArray;
29 | }
30 |
31 | return null;
32 | };
33 |
34 | const SCHOOL_FEATURE_SETS: FeatureSet[] = [
35 | [hasSchool, 4],
36 | [hasDegree, -4],
37 | [hasNumber, -4],
38 | ];
39 |
40 | const DEGREE_FEATURE_SETS: FeatureSet[] = [
41 | [hasDegree, 4],
42 | [hasSchool, -4],
43 | [hasNumber, -3],
44 | ];
45 |
46 | const GPA_FEATURE_SETS: FeatureSet[] = [
47 | [matchGPA, 4, true],
48 | [matchGrade, 3, true],
49 | [hasComma, -3],
50 | [hasLetter, -4],
51 | ];
52 |
53 | export const extractEducation = (sections: ResumeSectionToLines) => {
54 | const educations: ResumeEducation[] = [];
55 | const educationScores = [];
56 | const lines = getSectionLinesByKeywords(sections, ["education"]);
57 | const subsections = divideSectionIntoSubsections(lines);
58 | for (const subsectionsLines of subsections) {
59 | const textItems = subsectionsLines.flat();
60 | const [school, schoolScores] = getTextWithHighestFeatureScore(
61 | textItems,
62 | SCHOOL_FEATURE_SETS
63 | );
64 | const [degree, degreeScore] = getTextWithHighestFeatureScore(
65 | textItems,
66 | DEGREE_FEATURE_SETS
67 | );
68 | const [gpa, gpaScores] = getTextWithHighestFeatureScore(
69 | textItems,
70 | GPA_FEATURE_SETS
71 | );
72 | const [date, dateScores] = getTextWithHighestFeatureScore(
73 | textItems,
74 | DATE_FEATURE_SETS
75 | );
76 |
77 | let descriptions: string[] = [];
78 | const descriptionsLineIdx = getDescriptionsLineIdx(subsectionsLines);
79 | if (descriptionsLineIdx !== undefined) {
80 | const descriptionLines = subsectionsLines.slice(descriptionsLineIdx);
81 | descriptions = getBulletPointsFromLines(descriptionLines);
82 | }
83 |
84 | educations.push({ school, degree, gpa, date, descriptions });
85 | educationScores.push({
86 | schoolScores,
87 | degreeScore,
88 | gpaScores,
89 | dateScores,
90 | });
91 | }
92 |
93 | if (educations.length !== 0) {
94 | const courseLines = getSectionLinesByKeywords(sections, ["course"]);
95 | if (courseLines.length !== 0) {
96 | educations[0].descriptions.push(
97 | "Courses: " +
98 | courseLines
99 | .flat()
100 | .map((item) => item.text)
101 | .join(" ")
102 | );
103 | }
104 | }
105 |
106 | return {
107 | educations,
108 | educationScores,
109 | };
110 | };
111 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-profile.ts:
--------------------------------------------------------------------------------
1 | import {
2 | hasLetter,
3 | hasLetterAndIsAllUpperCase,
4 | isBold,
5 | } from "../group-lines-into-sections";
6 | import { FeatureSet, ResumeSectionToLines, TextItem } from "../types";
7 | import { getTextWithHighestFeatureScore } from "./lib/feature-scoring-system";
8 | import { getSectionLinesByKeywords } from "./lib/get-section-lines";
9 |
10 | //Name
11 | export const matchOnlyLetterSpaceOrPeriod = (item: TextItem) =>
12 | item.text.match(/^[A-Za-z\s\.]+$/);
13 |
14 | //Email
15 | export const matchEmail = (item: TextItem) => item.text.match(/\S+@\S+\.\S+/);
16 | const hasAt = (item: TextItem) => item.text.includes("@");
17 |
18 | //Phone
19 | export const matchPhone = (item: TextItem) =>
20 | item.text.match(/\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}/);
21 | const hasParenthesis = (item: TextItem) => /\([0-9]+\)/.test(item.text);
22 | export const hasNumber = (item: TextItem) => /[0-9]/.test(item.text);
23 |
24 | //Location
25 | export const matchCityAndState = (item: TextItem) =>
26 | item.text.match(/[A-Z][a-zA-Z\s]+,[A-Z]{2}/);
27 |
28 | export const hasComma = (item: TextItem) => item.text.includes(",");
29 |
30 | //Url
31 | export const matchUrl = (item: TextItem) => item.text.match(/\S+\.[a-z]+\/S+/);
32 |
33 | const matchUrlHttpFallback = (item: TextItem) =>
34 | item.text.match(/https?:\/\/S+\.\S+/);
35 |
36 | const matchUrlWwwFallback = (item: TextItem) =>
37 | item.text.match(/www\.\S+\.\S+/);
38 | const hasSlash = (item: TextItem) => item.text.includes("/");
39 |
40 | //Summary
41 | const has4OrMoreWords = (item: TextItem) => item.text.split(" ").length >= 4;
42 |
43 | const NAME_FEATURE_SETS: FeatureSet[] = [
44 | [matchOnlyLetterSpaceOrPeriod, 3, true],
45 | [isBold, 2],
46 | [hasLetterAndIsAllUpperCase, 2],
47 |
48 | [hasAt, -4],
49 | [hasNumber, -4],
50 | [hasParenthesis, -4],
51 | [hasSlash, -4],
52 | [hasComma, -4],
53 | [has4OrMoreWords, -2],
54 | ];
55 |
56 | const EMAIL_FEATURE_SETS: FeatureSet[] = [
57 | [matchEmail, 4, true],
58 | [isBold, -1],
59 | [hasLetterAndIsAllUpperCase, -1],
60 | [hasParenthesis, -4],
61 | [hasComma, -4],
62 | [hasSlash, -4],
63 | [has4OrMoreWords, -4],
64 | ];
65 |
66 | const PHONE_FEATURE_SETS: FeatureSet[] = [
67 | [matchPhone, 4, true],
68 | [hasLetter, -4],
69 | ];
70 |
71 | const LOCATION_FEATURE_SETS: FeatureSet[] = [
72 | [matchCityAndState, 4, true],
73 | [isBold, -1],
74 | [hasAt, -4],
75 | [hasParenthesis, -3],
76 | [hasSlash, -4],
77 | ];
78 |
79 | const URL_FEATURE_SETS: FeatureSet[] = [
80 | [matchUrl, 4, true],
81 | [matchUrlHttpFallback, 3, true],
82 | [matchUrlWwwFallback, 3, true],
83 | [isBold, -1],
84 | [hasAt, -4],
85 | [hasParenthesis, -3],
86 | [hasComma, -4],
87 | [has4OrMoreWords, -4],
88 | ];
89 |
90 | const SUMMARY_FEATURE_SETS: FeatureSet[] = [
91 | [has4OrMoreWords, 4],
92 | [isBold, -1],
93 | [hasAt, -4],
94 | [hasParenthesis, -3],
95 | [matchCityAndState, -4, false],
96 | ];
97 |
98 | export const extractProfile = (sections: ResumeSectionToLines) => {
99 | const lines = sections.profile || [];
100 | const textItems = lines.flat();
101 |
102 | const [name, nameScores] = getTextWithHighestFeatureScore(
103 | textItems,
104 | NAME_FEATURE_SETS
105 | );
106 |
107 | const [email, emailScores] = getTextWithHighestFeatureScore(
108 | textItems,
109 | EMAIL_FEATURE_SETS
110 | );
111 | const [phone, phoneScores] = getTextWithHighestFeatureScore(
112 | textItems,
113 | PHONE_FEATURE_SETS
114 | );
115 | const [location, locationScores] = getTextWithHighestFeatureScore(
116 | textItems,
117 | LOCATION_FEATURE_SETS
118 | );
119 | const [url, urlScores] = getTextWithHighestFeatureScore(
120 | textItems,
121 | URL_FEATURE_SETS
122 | );
123 | const [summary, summaryScores] = getTextWithHighestFeatureScore(
124 | textItems,
125 | SUMMARY_FEATURE_SETS,
126 | undefined,
127 | true
128 | );
129 |
130 | const summaryLines = getSectionLinesByKeywords(sections, ["summary"]);
131 | const summarySection = summaryLines
132 | .flat()
133 | .map((textItem) => textItem.text)
134 | .join(" ");
135 |
136 | const objectiveLines = getSectionLinesByKeywords(sections, ["objective"]);
137 | const objectiveSection = objectiveLines
138 | .flat()
139 | .map((textItem) => textItem.text)
140 | .join(" ");
141 |
142 | return {
143 | profile: {
144 | name,
145 | email,
146 | phone,
147 | location,
148 | url,
149 | summary: summarySection || objectiveSection || summary,
150 | },
151 | profileScores: {
152 | name: nameScores,
153 | email: emailScores,
154 | phone: phoneScores,
155 | location: locationScores,
156 | url: urlScores,
157 | summary: summaryScores,
158 | },
159 | };
160 | };
161 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-project.ts:
--------------------------------------------------------------------------------
1 | import { ResumeProject } from "../../redux/types";
2 | import { isBold } from "../group-lines-into-sections";
3 | import { FeatureSet, ResumeSectionToLines } from "../types";
4 | import {
5 | getBulletPointsFromLines,
6 | getDescriptionsLineIdx,
7 | } from "./lib/bullet-points";
8 | import { DATE_FEATURE_SETS, getHasText } from "./lib/common-features";
9 | import { getTextWithHighestFeatureScore } from "./lib/feature-scoring-system";
10 | import { getSectionLinesByKeywords } from "./lib/get-section-lines";
11 | import { divideSectionIntoSubsections } from "./lib/subsections";
12 |
13 | export const extractProject = (sections: ResumeSectionToLines) => {
14 | const projects: ResumeProject[] = [];
15 | const projectScores = [];
16 | const lines = getSectionLinesByKeywords(sections, ["project"]);
17 | const subsections = divideSectionIntoSubsections(lines);
18 |
19 | for (const subsectionLines of subsections) {
20 | const descriptionLineIdx = getDescriptionsLineIdx(subsectionLines) ?? 1;
21 |
22 | const subsectionInfoTextItems = subsectionLines
23 | .slice(0, descriptionLineIdx)
24 | .flat();
25 |
26 | const [date, dateScores] = getTextWithHighestFeatureScore(
27 | subsectionInfoTextItems,
28 | DATE_FEATURE_SETS
29 | );
30 |
31 | const PROJECT_FEATURE_SET: FeatureSet[] = [
32 | [isBold, 2],
33 | [getHasText(date), -4],
34 | ];
35 |
36 | const [project, projectScore] = getTextWithHighestFeatureScore(
37 | subsectionInfoTextItems,
38 | PROJECT_FEATURE_SET,
39 | false
40 | );
41 |
42 | const descriptionsLines = subsectionLines.slice(descriptionLineIdx);
43 | const descriptions = getBulletPointsFromLines(descriptionsLines);
44 |
45 | projects.push({ project, date, descriptions });
46 | projectScores.push({
47 | projectScore,
48 | dateScores,
49 | });
50 | }
51 |
52 | return { projects, projectScores };
53 | };
54 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-skill.ts:
--------------------------------------------------------------------------------
1 | import { initialFeaturedSkills } from "../../redux/resumeSlice";
2 | import { ResumeSkills } from "../../redux/types";
3 | import { deepClone } from "../deep-clone";
4 | import { ResumeSectionToLines } from "../types";
5 | import {
6 | getBulletPointsFromLines,
7 | getDescriptionsLineIdx,
8 | } from "./lib/bullet-points";
9 | import { getSectionLinesByKeywords } from "./lib/get-section-lines";
10 |
11 | export const extractSkills = (sections: ResumeSectionToLines) => {
12 | const lines = getSectionLinesByKeywords(sections, ["skill"]);
13 | const descriptionLineIdx = getDescriptionsLineIdx(lines) ?? 0;
14 | const descriptionLines = lines.slice(descriptionLineIdx);
15 | const descriptions = getBulletPointsFromLines(descriptionLines);
16 |
17 | const featuredSkills = deepClone(initialFeaturedSkills);
18 | if (descriptionLineIdx !== 0) {
19 | const featuredSkillLines = lines.slice(0, descriptionLineIdx);
20 | const featuredSkillsTextItems = featuredSkillLines
21 | .flat()
22 | .filter((item) => item.text.trim())
23 | .slice(0, 6);
24 |
25 | for (let i = 0; i < featuredSkillsTextItems.length; i++) {
26 | featuredSkills[i].skill = featuredSkillsTextItems[i].text;
27 | }
28 | }
29 |
30 | const skills: ResumeSkills = {
31 | featuredSkills,
32 | descriptions,
33 | };
34 |
35 | return { skills };
36 | };
37 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/extract-work-experience.ts:
--------------------------------------------------------------------------------
1 | import { ResumeWorkExperience } from "../../redux/types";
2 | import { isBold } from "../group-lines-into-sections";
3 | import { FeatureSet, ResumeSectionToLines, TextItem } from "../types";
4 | import { hasNumber } from "./extract-profile";
5 | import {
6 | getBulletPointsFromLines,
7 | getDescriptionsLineIdx,
8 | } from "./lib/bullet-points";
9 | import { DATE_FEATURE_SETS, getHasText } from "./lib/common-features";
10 | import { getTextWithHighestFeatureScore } from "./lib/feature-scoring-system";
11 | import { getSectionLinesByKeywords } from "./lib/get-section-lines";
12 | import { divideSectionIntoSubsections } from "./lib/subsections";
13 |
14 | const WORK_EXPERIENCE_KEYWORDS_LOWERCASE = [
15 | "work",
16 | "experience",
17 | "employment",
18 | "history",
19 | "job",
20 | ];
21 |
22 | const JOB_TITLES = [
23 | "Analyst",
24 | "Agent",
25 | "Administrator",
26 | "Architect",
27 | "Assistant",
28 | "Associate",
29 | "CTO",
30 | ];
31 |
32 | const hasJobTitle = (item: TextItem) =>
33 | JOB_TITLES.some((jobTitle) =>
34 | item.text.split(/\s/).some((word) => word === jobTitle)
35 | );
36 |
37 | const hasMoreThan5Words = (item: TextItem) => item.text.split(/\s/).length > 5;
38 |
39 | const JOB_TITLE_FEATURE_LIST: FeatureSet[] = [
40 | [hasJobTitle, 4],
41 | [hasNumber, -4],
42 | [hasMoreThan5Words, -2],
43 | ];
44 |
45 | export const extractWorkExperience = (sections: ResumeSectionToLines) => {
46 | const workExperiences: ResumeWorkExperience[] = [];
47 | const workExperiencesScores = [];
48 | const lines = getSectionLinesByKeywords(
49 | sections,
50 | WORK_EXPERIENCE_KEYWORDS_LOWERCASE
51 | );
52 | const subsections = divideSectionIntoSubsections(lines);
53 |
54 | for (const subsectionLines of subsections) {
55 | const descriptionsLineIdx = getDescriptionsLineIdx(subsectionLines) ?? 2;
56 |
57 | const subsectionInfoTextItems = subsectionLines
58 | .slice(0, descriptionsLineIdx)
59 | .flat();
60 |
61 | const [date, dateScores] = getTextWithHighestFeatureScore(
62 | subsectionInfoTextItems,
63 | DATE_FEATURE_SETS
64 | );
65 |
66 | const [jobTitle, jonTitleScores] = getTextWithHighestFeatureScore(
67 | subsectionInfoTextItems,
68 | JOB_TITLE_FEATURE_LIST
69 | );
70 |
71 | const COMPANY_FEATURE_SET: FeatureSet[] = [
72 | [isBold, 2],
73 | [getHasText(date), -4],
74 | [getHasText(jobTitle), -4],
75 | ];
76 |
77 | const [company, companyScores] = getTextWithHighestFeatureScore(
78 | subsectionInfoTextItems,
79 | COMPANY_FEATURE_SET,
80 | false
81 | );
82 |
83 | const subsectionDescriptionLines =
84 | subsectionLines.slice(descriptionsLineIdx);
85 | const descriptions = getBulletPointsFromLines(subsectionDescriptionLines);
86 |
87 | workExperiences.push({ company, jobTitle, date, descriptions });
88 | workExperiencesScores.push({
89 | companyScores,
90 | jonTitleScores,
91 | dateScores,
92 | });
93 | }
94 |
95 | return { workExperiences, workExperiencesScores };
96 | };
97 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/index.ts:
--------------------------------------------------------------------------------
1 | import { Resume } from "../../redux/types";
2 | import { ResumeSectionToLines } from "../types";
3 | import { extractEducation } from "./extract-education";
4 | import { extractProfile } from "./extract-profile";
5 | import { extractProject } from "./extract-project";
6 | import { extractSkills } from "./extract-skill";
7 | import { extractWorkExperience } from "./extract-work-experience";
8 |
9 | export const extractResumeFromSections = (
10 | sections: ResumeSectionToLines
11 | ): Resume => {
12 | const { profile } = extractProfile(sections);
13 | const { educations } = extractEducation(sections);
14 | const { workExperiences } = extractWorkExperience(sections);
15 | const { projects } = extractProject(sections);
16 | const { skills } = extractSkills(sections);
17 |
18 | return {
19 | profile,
20 | educations,
21 | workExperiences,
22 | projects,
23 | skills,
24 | custom: {
25 | descriptions: [],
26 | },
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/bullet-points.ts:
--------------------------------------------------------------------------------
1 | import { Lines, TextItem } from "../../types";
2 |
3 | export const BULLET_POINTS = [
4 | "⋅",
5 | "∙",
6 | "🞄",
7 | "•",
8 | "⦁",
9 | "⚫︎",
10 | "●",
11 | "⬤",
12 | "⚬",
13 | "○",
14 | ];
15 |
16 | const getFirstBulletPointLineIdx = (lines: Lines): number | undefined => {
17 | for (let i = 0; i < lines.length; i++) {
18 | for (let item of lines[i]) {
19 | if (BULLET_POINTS.some((bullet) => item.text.includes(bullet))) {
20 | return i;
21 | }
22 | }
23 | }
24 |
25 | return undefined;
26 | };
27 |
28 | const isWord = (str: string) => /^[^0-9]+$/.test(str);
29 |
30 | const hasAtLeast8Words = (item: TextItem) =>
31 | item.text.split(/\s/).filter(isWord).length >= 8;
32 |
33 | export const getDescriptionsLineIdx = (lines: Lines): number | undefined => {
34 | let idx = getFirstBulletPointLineIdx(lines);
35 |
36 | if (idx === undefined) {
37 | for (let i = 0; i < lines.length; i++) {
38 | const line = lines[i];
39 | if (line.length === 1 && hasAtLeast8Words(line[0])) {
40 | idx = i;
41 | break;
42 | }
43 | }
44 | }
45 |
46 | return idx;
47 | };
48 |
49 | export const getBulletPointsFromLines = (lines: Lines): string[] => {
50 | const firstBulletPointLineIndex = getFirstBulletPointLineIdx(lines);
51 | if (firstBulletPointLineIndex === undefined) {
52 | return lines.map((line) => line.map((item) => item.text).join(" "));
53 | }
54 |
55 | let lineStr = "";
56 | for (let item of lines.flat()) {
57 | const text = item.text;
58 |
59 | if (!lineStr.endsWith(" ") && !text.startsWith(" ")) {
60 | lineStr += " ";
61 | }
62 | lineStr += text;
63 | }
64 |
65 | const commonBulletPoint = getMostCommonBulletPoint(lineStr);
66 |
67 | const firstBulletPointIndex = lineStr.indexOf(commonBulletPoint);
68 | if (firstBulletPointIndex !== -1) {
69 | lineStr = lineStr.slice(firstBulletPointIndex);
70 | }
71 |
72 | return lineStr
73 | .split(commonBulletPoint)
74 | .map((text) => text.trim())
75 | .filter((text) => !!text);
76 | };
77 |
78 | const getMostCommonBulletPoint = (str: string): string => {
79 | const bulletToCount: { [bullet: string]: number } = BULLET_POINTS.reduce(
80 | (acc: { [bullet: string]: number }, curr) => {
81 | acc[curr] = 0;
82 | return acc;
83 | },
84 | {}
85 | );
86 |
87 | let bulletWithMostCount = BULLET_POINTS[0];
88 | let bulletMaxCount = 0;
89 | for (let char of str) {
90 | if (bulletToCount.hasOwnProperty(char)) {
91 | bulletToCount[char]++;
92 | if (bulletToCount[char] > bulletMaxCount) {
93 | bulletWithMostCount = char;
94 | }
95 | }
96 | }
97 |
98 | return bulletWithMostCount;
99 | };
100 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/common-features.ts:
--------------------------------------------------------------------------------
1 | export const getHasText = (text: string) => (item: TextItem) =>
2 | item.text.includes(text);
3 |
4 | //Date Features
5 |
6 | import { FeatureSet, TextItem } from "../../types";
7 | import { hasComma } from "../extract-profile";
8 |
9 | const hasYear = (item: TextItem) => /(?:19|20)\d{2}/.test(item.text);
10 |
11 | const MONTHS = [
12 | "January",
13 | "February",
14 | "March",
15 | "April",
16 | "May",
17 | "June",
18 | "July",
19 | "August",
20 | "September",
21 | "October",
22 | "November",
23 | "December",
24 | ];
25 |
26 | const hasMonth = (item: TextItem) =>
27 | MONTHS.some(
28 | (month) =>
29 | item.text.includes(month) || item.text.includes(month.slice(0, 4))
30 | );
31 |
32 | const SEASONS = ["Summer", "Fall", "Spring", "Winter"];
33 |
34 | const hasSeason = (item: TextItem) =>
35 | SEASONS.some((season) => item.text.includes(season));
36 |
37 | const hasPresent = (item: TextItem) => item.text.includes("Present");
38 |
39 | export const DATE_FEATURE_SETS: FeatureSet[] = [
40 | [hasYear, 1],
41 | [hasMonth, 1],
42 | [hasSeason, 1],
43 | [hasPresent, 1],
44 | [hasComma, -1],
45 | ];
46 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/feature-scoring-system.ts:
--------------------------------------------------------------------------------
1 | import { FeatureSet, TextItems, TextScores } from "../../types";
2 |
3 | const computeFeatureScores = (
4 | textItems: TextItems,
5 | featureSets: FeatureSet[]
6 | ): TextScores => {
7 | const textScores = textItems.map((item) => ({
8 | text: item.text,
9 | score: 0,
10 | match: false,
11 | }));
12 |
13 | for (let i = 0; i < textItems.length; i++) {
14 | const textItem = textItems[i];
15 |
16 | for (const featureSet of featureSets) {
17 | const [hasFeature, score, returnMatchingText] = featureSet;
18 | const result = hasFeature(textItem);
19 | if (result) {
20 | let text = textItem.text;
21 | if (returnMatchingText && typeof result === "object") {
22 | text = result[0];
23 | }
24 |
25 | const textScore = textScores[i];
26 | if (textItem.text === text) {
27 | textScore.score += score;
28 | if (returnMatchingText) {
29 | textScore.match = true;
30 | }
31 | } else {
32 | textScores.push({ text, score, match: true });
33 | }
34 | }
35 | }
36 | }
37 | return textScores;
38 | };
39 |
40 | export const getTextWithHighestFeatureScore = (
41 | textItems: TextItems,
42 | featureSets: FeatureSet[],
43 | returnEmptyStringIfHighestScoreIsNotPositive = true,
44 | returnConcatenatedStringForTextsWithSameHighestScore = false
45 | ) => {
46 | const textScores = computeFeatureScores(textItems, featureSets);
47 |
48 | let textsWithHighestFeatureScore: string[] = [];
49 | let highestScore = -Infinity;
50 | for (const { text, score } of textScores) {
51 | if (score >= highestScore) {
52 | if (score > highestScore) {
53 | textsWithHighestFeatureScore = [];
54 | }
55 |
56 | textsWithHighestFeatureScore.push(text);
57 | highestScore = score;
58 | }
59 | }
60 |
61 | if (returnEmptyStringIfHighestScoreIsNotPositive && highestScore <= 0) {
62 | return ["", textScores] as const;
63 | }
64 |
65 | const text = !returnConcatenatedStringForTextsWithSameHighestScore
66 | ? textsWithHighestFeatureScore[0] ?? ""
67 | : textsWithHighestFeatureScore.map((s) => s.trim()).join(" ");
68 |
69 | return [text, textScores] as const;
70 | };
71 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/get-section-lines.ts:
--------------------------------------------------------------------------------
1 | import { ResumeSectionToLines } from "../../types";
2 |
3 | export const getSectionLinesByKeywords = (
4 | sections: ResumeSectionToLines,
5 | keywords: string[]
6 | ) => {
7 | for (const sectionName in sections) {
8 | const hasKeyWord = keywords.some((keyword) =>
9 | sectionName.toLowerCase().includes(keyword)
10 | );
11 | if (hasKeyWord) {
12 | return sections[sectionName];
13 | }
14 | }
15 |
16 | return [];
17 | };
18 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/extract-resume-from-sections/lib/subsections.ts:
--------------------------------------------------------------------------------
1 | import { isBold } from "../../group-lines-into-sections";
2 | import { Line, Lines, Subsections } from "../../types";
3 |
4 | export const divideSectionIntoSubsections = (lines: Lines): Subsections => {
5 | const isLineNewSubsectionByLineGap =
6 | createIsLineNewSubsectionByLineGap(lines);
7 |
8 | let subsections = createSubsections(lines, isLineNewSubsectionByLineGap);
9 |
10 | if (subsections.length === 1) {
11 | const isLineNewSubsectionByBold = (line: Line, prevLine: Line) => {
12 | if (isBold(prevLine[0]) && isBold(line[0])) {
13 | return true;
14 | }
15 | return false;
16 | };
17 |
18 | subsections = createSubsections(lines, isLineNewSubsectionByBold);
19 | }
20 |
21 | return subsections;
22 | };
23 |
24 | type IsLineNewSubsection = (line: Line, prevLine: Line) => boolean;
25 |
26 | const createIsLineNewSubsectionByLineGap = (
27 | lines: Lines
28 | ): IsLineNewSubsection => {
29 | const lineGapToCount: { [lineGap: number]: number } = {};
30 | const linesY = lines.map((line) => line[0].y);
31 |
32 | let lineGapWithMostCount: number = 0;
33 | let maxCount = 0;
34 | for (let i = 1; i < linesY.length; i++) {
35 | const lineGap = Math.round(linesY[i - 1] - linesY[i]);
36 | if (!lineGapToCount[lineGap]) lineGapToCount[lineGap] = 0;
37 | lineGapToCount[lineGap] += 1;
38 | if (lineGapToCount[lineGap] > maxCount) {
39 | lineGapWithMostCount = lineGap;
40 | maxCount = lineGapToCount[lineGap];
41 | }
42 | }
43 |
44 | const subsectionLineGapThreshold = lineGapWithMostCount * 1.4;
45 |
46 | const isLineNewSubsection = (line: Line, prevLine: Line) => {
47 | return Math.round(prevLine[0].y - line[0].y) > subsectionLineGapThreshold;
48 | };
49 |
50 | return isLineNewSubsection;
51 | };
52 |
53 | const createSubsections = (
54 | lines: Lines,
55 | isLineNewSubsection: IsLineNewSubsection
56 | ): Subsections => {
57 | const subsections: Subsections = [];
58 |
59 | let subsection: Lines = [];
60 |
61 | for (let i = 0; i < lines.length; i++) {
62 | const line = lines[i];
63 | if (i === 0) {
64 | subsection.push(line);
65 | continue;
66 | }
67 | if (isLineNewSubsection(line, lines[i - 1])) {
68 | subsections.push(subsection);
69 | subsection = [];
70 | }
71 | subsection.push(line);
72 | }
73 | if (subsection.length > 0) {
74 | subsections.push(subsection);
75 | }
76 | return subsections;
77 | };
78 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/group-lines-into-sections.ts:
--------------------------------------------------------------------------------
1 | import { ResumeKey } from "../redux/types";
2 | import { Line, Lines, ResumeSectionToLines, TextItem } from "./types";
3 |
4 | export const PROFILE_SECTION: ResumeKey = "profile";
5 |
6 | const SECTION_TITLE_PRIMARY_KEYWORDS = [
7 | "experience",
8 | "education",
9 | "project",
10 | "skill",
11 | ];
12 |
13 | const SECTION_TITLE_SECONDARY_KEYWORDS = [
14 | "job",
15 | "course",
16 | "extracurricular",
17 | "objective",
18 | "summary",
19 | "award",
20 | "honor",
21 | "project",
22 | ];
23 |
24 | const SECTION_TITLE_KEYWORDS = [
25 | ...SECTION_TITLE_PRIMARY_KEYWORDS,
26 | ...SECTION_TITLE_SECONDARY_KEYWORDS,
27 | ];
28 |
29 | export const groupLinesIntoSections = (lines: Lines) => {
30 | let sections: ResumeSectionToLines = {};
31 | let sectionName: string = PROFILE_SECTION;
32 | let sectionLines: any = [];
33 |
34 | for (let i = 0; i < lines.length; i++) {
35 | const line = lines[i];
36 | const text = line[0]?.text.trim();
37 | if (isSectionTitle(line, i)) {
38 | sections[sectionName] = [...sectionLines];
39 | sectionName = text;
40 | sectionLines = [];
41 | } else {
42 | sectionLines.push(line);
43 | }
44 | }
45 | if (sectionLines.length > 0) {
46 | sections[sectionName] = [...sectionLines];
47 | }
48 | return sections;
49 | };
50 |
51 | const isSectionTitle = (line: Line, lineNumber: number) => {
52 | const isFirstTwoLines = lineNumber < 2;
53 | const hasMoreThanOneItemInLine = line.length > 1;
54 | const hasNoItemInLine = line.length === 0;
55 | if (isFirstTwoLines || hasMoreThanOneItemInLine || hasNoItemInLine) {
56 | return false;
57 | }
58 |
59 | const textItem = line[0];
60 |
61 | if (isBold(textItem) && hasLetterAndIsAllUpperCase(textItem)) {
62 | return true;
63 | }
64 |
65 | const text = textItem.text.trim();
66 |
67 | const textHasAtMost2Words =
68 | text.split(" ").filter((s) => s !== "&").length <= 2;
69 | const startsWithCapitalLetter = /[A-Z]/.test(text.slice(0, 1));
70 |
71 | if (
72 | textHasAtMost2Words &&
73 | hasOnlyLettersSpacesAmpersands(textItem) &&
74 | startsWithCapitalLetter &&
75 | SECTION_TITLE_KEYWORDS.some((keyword) =>
76 | text.toLowerCase().includes(keyword)
77 | )
78 | ) {
79 | return true;
80 | }
81 |
82 | return false;
83 | };
84 |
85 | export const isBold = (item: TextItem) => isTextItemBold(item.fontName);
86 |
87 | const isTextItemBold = (fontName: string) =>
88 | fontName.toLowerCase().includes("bold");
89 |
90 | export const hasLetter = (item: TextItem) => /[a-zA-Z]/.test(item.text);
91 |
92 | export const hasLetterAndIsAllUpperCase = (item: TextItem) =>
93 | hasLetter(item) && item.text.toUpperCase() === item.text;
94 |
95 | export const hasOnlyLettersSpacesAmpersands = (item: TextItem) =>
96 | /^[A-Za-z\s&]+$/.test(item.text);
97 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/group-text-items-into-lines.ts:
--------------------------------------------------------------------------------
1 | import { Line, Lines, TextItems } from "./types";
2 |
3 | export const groupTextItemsIntoLines = (textItems: TextItems): Lines => {
4 | const lines: Lines = [];
5 |
6 | let line: Line = [];
7 | for (let item of textItems) {
8 | if (item.hasEOL) {
9 | if (item.text.trim() !== "") {
10 | line.push({ ...item });
11 | }
12 | lines.push(line);
13 | line = [];
14 | } else if (item.text.trim() !== "") {
15 | line.push({ ...item });
16 | }
17 | }
18 | if (line.length > 0) {
19 | lines.push(line);
20 | }
21 |
22 | const typicalCharWidth = getTypicalCharWidth(lines.flat());
23 |
24 | for (let line of lines) {
25 | for (let i = line.length - 1; i > 0; i--) {
26 | const currentItem = line[i];
27 | const leftItem = line[i - 1];
28 | const leftItemXEnd = leftItem.x + leftItem.width;
29 | const distance = currentItem.x - leftItemXEnd;
30 | if (distance <= typicalCharWidth) {
31 | if (shouldAddSpaceBetweenText(leftItem.text, currentItem.text)) {
32 | leftItem.text += " ";
33 | }
34 | leftItem.text += currentItem.text;
35 |
36 | const currentItemXEnd = currentItem.x + currentItem.width;
37 | leftItem.width = currentItemXEnd - leftItem.x;
38 | line.splice(i, 1);
39 | }
40 | }
41 | }
42 |
43 | return lines;
44 | };
45 |
46 | const shouldAddSpaceBetweenText = (leftText: string, rightText: string) => {
47 | const leftTextEnd = leftText[leftText.length - 1];
48 | const rightTextStart = rightText[0];
49 | const conditions = [
50 | [":", ",", "|", "."].includes(leftTextEnd) && rightTextStart !== " ",
51 | leftTextEnd !== " " && ["|"].includes(rightTextStart),
52 | ];
53 |
54 | return conditions.some((condition) => condition);
55 | };
56 |
57 | const getTypicalCharWidth = (textItems: TextItems): number => {
58 | textItems = textItems.filter((item) => item.text.trim() !== "");
59 |
60 | const heightToCount: { [height: number]: number } = {};
61 | let commonHeight = 0;
62 | let heightMaxCount = 0;
63 |
64 | const fontNameToCount: { [fontName: string]: number } = {};
65 | let commonFontName = "";
66 | let fontNameMaxCount = 0;
67 |
68 | for (let item of textItems) {
69 | const { text, height, fontName } = item;
70 |
71 | if (!heightToCount[height]) {
72 | heightToCount[height] = 0;
73 | }
74 | heightToCount[height]++;
75 | if (heightToCount[height] > heightMaxCount) {
76 | commonHeight = height;
77 | heightMaxCount = heightToCount[height];
78 | }
79 |
80 | if (!fontNameToCount[fontName]) {
81 | fontNameToCount[fontName] = 0;
82 | }
83 | fontNameToCount[fontName] += text.length;
84 | if (fontNameToCount[fontName] > fontNameMaxCount) {
85 | commonFontName = fontName;
86 | fontNameMaxCount = fontNameToCount[fontName];
87 | }
88 | }
89 |
90 | const commonTextItems = textItems.filter(
91 | (item) => item.fontName === commonFontName && item.height === commonHeight
92 | );
93 |
94 | const [totalWidth, numChars] = commonTextItems.reduce(
95 | (acc, cur) => {
96 | const [preWidth, prevChars] = acc;
97 | return [preWidth + cur.width, prevChars + cur.text.length];
98 | },
99 | [0, 0]
100 | );
101 |
102 | const typicalCharWidth = totalWidth / numChars;
103 |
104 | return typicalCharWidth;
105 | };
106 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/index.ts:
--------------------------------------------------------------------------------
1 | import { extractResumeFromSections } from "./extract-resume-from-sections";
2 | import { groupLinesIntoSections } from "./group-lines-into-sections";
3 | import { groupTextItemsIntoLines } from "./group-text-items-into-lines";
4 | import { readPdf } from "./read-pdf";
5 |
6 | export const parseResumeFromPdf = async (fileUrl: string) => {
7 | //step 1. Read a pdf resume file into text items to prepare for processing
8 | const textItems = await readPdf(fileUrl);
9 |
10 | //step 2. Group text items into lines
11 | const lines = groupTextItemsIntoLines(textItems);
12 |
13 | //step 3. Group lines into sections
14 | const sections = groupLinesIntoSections(lines);
15 |
16 | //step 4. Extract resume from sections
17 | const resume = extractResumeFromSections(sections);
18 |
19 | return resume;
20 | };
21 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/read-pdf.ts:
--------------------------------------------------------------------------------
1 | import { TextItem, TextItems } from "./types";
2 | import * as pdfjs from "pdfjs-dist";
3 |
4 | import pdfjsWorker from "pdfjs-dist/build/pdf.worker.entry";
5 | pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
6 |
7 | import type { TextItem as PdfjsTextItem } from "pdfjs-dist/types/src/display/api";
8 |
9 | export const readPdf = async (fileUrl: string): Promise => {
10 | const pdffile = await pdfjs.getDocument(fileUrl).promise;
11 | let textItems: TextItems = [];
12 |
13 | for (let i = 1; i <= pdffile.numPages; i++) {
14 | const page = await pdffile.getPage(i);
15 | const textContent = await page.getTextContent();
16 |
17 | await page.getOperatorList();
18 | const commonObjs = page.commonObjs;
19 |
20 | const pageTextItems = textContent.items.map((item) => {
21 | const {
22 | str: text,
23 | dir,
24 | transform,
25 | fontName: pdfFontName,
26 | ...otherProps
27 | } = item as PdfjsTextItem;
28 |
29 | const x = transform[4];
30 | const y = transform[5];
31 |
32 | const fontObj = commonObjs.get(pdfFontName);
33 | const fontName = fontObj.name;
34 |
35 | const newText = text.replace(/--/g, "-");
36 |
37 | const newItem = {
38 | ...otherProps,
39 | fontName,
40 | text: newText,
41 | x,
42 | y,
43 | };
44 |
45 | return newItem;
46 | });
47 |
48 | textItems.push(...pageTextItems);
49 | }
50 |
51 | const isEmptySpace = (textItem: TextItem) =>
52 | !textItem.hasEOL && textItem.text.trim() === "";
53 |
54 | textItems = textItems.filter((textItem) => !isEmptySpace(textItem));
55 |
56 | return textItems;
57 | };
58 |
--------------------------------------------------------------------------------
/app/lib/parse-resume-from-pdf/types.ts:
--------------------------------------------------------------------------------
1 | import type { ResumeKey } from "../redux/types";
2 | export interface TextItem {
3 | text: string;
4 | x: number;
5 | y: number;
6 | width: number;
7 | height: number;
8 | fontName: string;
9 | hasEOL: boolean;
10 | }
11 |
12 | export type TextItems = TextItem[];
13 |
14 | export type Line = TextItem[];
15 | export type Lines = Line[];
16 |
17 | export type Subsections = Lines[];
18 |
19 | export type ResumeSectionToLines = { [sectionName in ResumeKey]?: Lines } & {
20 | [otherSectionName: string]: Lines;
21 | };
22 |
23 | type FeatureScore = -4 | -3 | -2 | -1 | 0 | 1 | 2 | 3 | 4;
24 | type ReturnMatchingTextOnly = boolean;
25 |
26 | export type FeatureSet =
27 | | [(item: TextItem) => boolean, FeatureScore]
28 | | [
29 | (item: TextItem) => RegExpMatchArray | null,
30 | FeatureScore,
31 | ReturnMatchingTextOnly
32 | ];
33 |
34 | export interface TextScore {
35 | text: string;
36 | score: number;
37 | match: boolean;
38 | }
39 |
40 | export type TextScores = TextScore[];
41 |
--------------------------------------------------------------------------------
/app/lib/redux/hooks.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { AppDispatch, RootState, store } from "./store";
3 | import {
4 | loadStateFromLocalStorage,
5 | saveStateToLocalStorage,
6 | } from "./local-storage";
7 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
8 | import { deepMerge } from "../deep-merge";
9 | import { initialResumeState, setResume } from "./resumeSlice";
10 | import { Resume } from "./types";
11 | import { Settings, initialSettings, setSettings } from "./settingsSlice";
12 |
13 | export const useAppDispatch: () => AppDispatch = useDispatch;
14 | export const useAppSelector: TypedUseSelectorHook = useSelector;
15 |
16 | export const useSaveStateToLocalStorageOnChange = () => {
17 | useEffect(() => {
18 | const unsubscribe = store.subscribe(() => {
19 | saveStateToLocalStorage(store.getState());
20 | });
21 | return unsubscribe;
22 | }, []);
23 | };
24 |
25 | export const useSetInitialStore = () => {
26 | const dispatch = useAppDispatch();
27 | useEffect(() => {
28 | const state = loadStateFromLocalStorage();
29 | if (!state) return;
30 | if (state.resume) {
31 | const mergedResumeState = deepMerge(
32 | initialResumeState,
33 | state.resume
34 | ) as Resume;
35 | dispatch(setResume(mergedResumeState));
36 | }
37 | if (state.settings) {
38 | const mergedSettingsState = deepMerge(
39 | initialSettings,
40 | state.settings
41 | ) as Settings;
42 | dispatch(setSettings(mergedSettingsState));
43 | }
44 | }, []);
45 | };
46 |
--------------------------------------------------------------------------------
/app/lib/redux/local-storage.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from "./store";
2 |
3 | const LOCAL_STORAGE_KEY = "resume-builder-parser-state";
4 |
5 | export const saveStateToLocalStorage = (state: RootState) => {
6 | try {
7 | const stringifiedState = JSON.stringify(state);
8 | localStorage.setItem(LOCAL_STORAGE_KEY, stringifiedState);
9 | } catch (e) {}
10 | };
11 |
12 | export const loadStateFromLocalStorage = () => {
13 | try {
14 | const stringifiedState = localStorage.getItem(LOCAL_STORAGE_KEY);
15 | if (!stringifiedState) return undefined;
16 | return JSON.parse(stringifiedState);
17 | } catch (e) {
18 | return undefined;
19 | }
20 | };
21 |
22 | export const getHasUsedAppBefore = () => Boolean(loadStateFromLocalStorage());
23 |
--------------------------------------------------------------------------------
/app/lib/redux/resumeSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
2 | import {
3 | FeaturedSkill,
4 | Resume,
5 | ResumeEducation,
6 | ResumeProfile,
7 | ResumeProject,
8 | ResumeSkills,
9 | ResumeWorkExperience,
10 | } from "./types";
11 | import { ShowForm } from "./settingsSlice";
12 | import { RootState } from "./store";
13 |
14 | export const initialProfile: ResumeProfile = {
15 | name: "",
16 | summary: "",
17 | email: "",
18 | phone: "",
19 | location: "",
20 | url: "",
21 | };
22 |
23 | export const initialWorkExperience: ResumeWorkExperience = {
24 | company: "",
25 | jobTitle: "",
26 | date: "",
27 | descriptions: [],
28 | };
29 |
30 | export const initialEducation: ResumeEducation = {
31 | school: "",
32 | date: "",
33 | degree: "",
34 | gpa: "",
35 | descriptions: [],
36 | };
37 |
38 | export const initialProject: ResumeProject = {
39 | project: "",
40 | date: "",
41 | descriptions: [],
42 | };
43 |
44 | export const initialFeaturedSkill: FeaturedSkill = { skill: "", rating: 4 };
45 | export const initialFeaturedSkills: FeaturedSkill[] = Array(6).fill({
46 | ...initialFeaturedSkill,
47 | });
48 |
49 | export const initialSkills: ResumeSkills = {
50 | featuredSkills: initialFeaturedSkills,
51 | descriptions: [],
52 | };
53 |
54 | export const initialCustom = {
55 | descriptions: [],
56 | };
57 |
58 | export const initialResumeState: Resume = {
59 | profile: initialProfile,
60 | workExperiences: [initialWorkExperience],
61 | educations: [initialEducation],
62 | projects: [initialProject],
63 | skills: initialSkills,
64 | custom: initialCustom,
65 | };
66 |
67 | export type CreateChangeActionWithDescriptions = {
68 | idx: number;
69 | } & (
70 | | {
71 | field: Exclude;
72 | value: string;
73 | }
74 | | {
75 | field: "descriptions";
76 | value: string[];
77 | }
78 | );
79 |
80 | export const resumeSlice = createSlice({
81 | name: "resume",
82 | initialState: initialResumeState,
83 | reducers: {
84 | changeProfile: (
85 | draft,
86 | action: PayloadAction<{ field: keyof ResumeProfile; value: string }>
87 | ) => {
88 | const { field, value } = action.payload;
89 | draft.profile[field] = value;
90 | },
91 |
92 | changeWorkExperience: (
93 | draft,
94 | action: PayloadAction<
95 | CreateChangeActionWithDescriptions
96 | >
97 | ) => {
98 | const { idx, field, value } = action.payload;
99 | const workExperience = draft.workExperiences[idx];
100 | workExperience[field] = value as any;
101 | },
102 | changeEducations: (
103 | draft,
104 | action: PayloadAction>
105 | ) => {
106 | const { idx, field, value } = action.payload;
107 | const education = draft.educations[idx];
108 | education[field] = value as any;
109 | },
110 | changeProjects: (
111 | draft,
112 | action: PayloadAction>
113 | ) => {
114 | const { idx, field, value } = action.payload;
115 | const project = draft.projects[idx];
116 | project[field] = value as any;
117 | },
118 | changeSkills: (
119 | draft,
120 | action: PayloadAction<
121 | | { field: "descriptions"; value: string[] }
122 | | {
123 | field: "featuredSkills";
124 | idx: number;
125 | skill: string;
126 | rating: number;
127 | }
128 | >
129 | ) => {
130 | const { field } = action.payload;
131 | if (field === "descriptions") {
132 | const { value } = action.payload;
133 | draft.skills.descriptions = value;
134 | } else {
135 | const { idx, skill, rating } = action.payload;
136 | const featuredSkill = draft.skills.featuredSkills[idx];
137 | featuredSkill.skill = skill;
138 | featuredSkill.rating = rating;
139 | }
140 | },
141 | changeCustom: (
142 | draft,
143 | action: PayloadAction<{ field: "descriptions"; value: string[] }>
144 | ) => {
145 | const { value } = action.payload;
146 | draft.custom.descriptions = value;
147 | },
148 | addSectionInForm: (draft, action: PayloadAction<{ form: ShowForm }>) => {
149 | const { form } = action.payload;
150 | switch (form) {
151 | case "workExperiences": {
152 | draft.workExperiences.push(structuredClone(initialWorkExperience));
153 | return draft;
154 | }
155 | case "educations": {
156 | draft.educations.push(structuredClone(initialEducation));
157 | return draft;
158 | }
159 | case "projects": {
160 | draft.projects.push(structuredClone(initialProject));
161 | return draft;
162 | }
163 | }
164 | },
165 | moveSectionInForm: (
166 | draft,
167 | action: PayloadAction<{
168 | form: ShowForm;
169 | idx: number;
170 | direction: "up" | "down";
171 | }>
172 | ) => {
173 | const { form, idx, direction } = action.payload;
174 | if (form !== "skills" && form !== "custom") {
175 | if (
176 | (idx === 0 && direction === "up") ||
177 | (idx === draft[form].length - 1 && direction === "down")
178 | ) {
179 | return draft;
180 | }
181 |
182 | const section = draft[form][idx];
183 | if (direction === "up") {
184 | draft[form][idx] = draft[form][idx - 1];
185 | draft[form][idx - 1] = section;
186 | } else {
187 | draft[form][idx] = draft[form][idx + 1];
188 | draft[form][idx + 1] = section;
189 | }
190 | }
191 | },
192 | deleteSectionInFormByIdx: (
193 | draft,
194 | action: PayloadAction<{ form: ShowForm; idx: number }>
195 | ) => {
196 | const { form, idx } = action.payload;
197 | if (form !== "skills" && form !== "custom") {
198 | draft[form].splice(idx, 1);
199 | }
200 | },
201 | setResume: (draft, action: PayloadAction) => {
202 | return action.payload;
203 | },
204 | },
205 | });
206 |
207 | export const {
208 | changeCustom,
209 | changeEducations,
210 | changeProjects,
211 | changeProfile,
212 | changeSkills,
213 | changeWorkExperience,
214 | addSectionInForm,
215 | moveSectionInForm,
216 | deleteSectionInFormByIdx,
217 | setResume,
218 | } = resumeSlice.actions;
219 |
220 | export const selectResume = (state: RootState) => state.resume;
221 | export const selectProfile = (state: RootState) => state.resume.profile;
222 | export const selectWorkExperiences = (state: RootState) =>
223 | state.resume.workExperiences;
224 | export const selectEducations = (state: RootState) => state.resume.educations;
225 | export const selectProjects = (state: RootState) => state.resume.projects;
226 | export const selectSkills = (state: RootState) => state.resume.skills;
227 | export const selectCustom = (state: RootState) => state.resume.custom;
228 |
229 | export default resumeSlice.reducer;
230 |
--------------------------------------------------------------------------------
/app/lib/redux/settingsSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
2 | import { act } from "react-dom/test-utils";
3 | import { RootState } from "./store";
4 |
5 | export interface Settings {
6 | themeColor: string;
7 | fontFamily: string;
8 | fontSize: string;
9 | documentSize: string;
10 | formToShow: {
11 | workExperiences: boolean;
12 | educations: boolean;
13 | projects: boolean;
14 | skills: boolean;
15 | custom: boolean;
16 | };
17 | formToHeading: {
18 | workExperiences: string;
19 | educations: string;
20 | projects: string;
21 | skills: string;
22 | custom: string;
23 | };
24 | formsOrder: ShowForm[];
25 | showBulletPoints: {
26 | educations: boolean;
27 | projects: boolean;
28 | skills: boolean;
29 | custom: boolean;
30 | };
31 | }
32 |
33 | export type ShowForm = keyof Settings["formToShow"];
34 | export type FormWithBulletPoints = keyof Settings["showBulletPoints"];
35 | export type GeneralSetting = Exclude<
36 | keyof Settings,
37 | "formToShow" | "formToHeading" | "formsOrder" | "showBulletPoints"
38 | >;
39 |
40 | export const DEFAULT_THEME_COLOR = "#38bdf8";
41 | export const DEAFULT_FONT_FAMILY = "Roboto";
42 | export const DEFAULT_FONT_SIZE = "11";
43 | export const DEFAULT_FONT_COLOR = "#171717";
44 |
45 | export const initialSettings: Settings = {
46 | themeColor: DEFAULT_THEME_COLOR,
47 | fontFamily: DEAFULT_FONT_FAMILY,
48 | fontSize: DEFAULT_FONT_SIZE,
49 | documentSize: "Letter",
50 | formToShow: {
51 | workExperiences: true,
52 | educations: true,
53 | projects: true,
54 | skills: true,
55 | custom: true,
56 | },
57 | formToHeading: {
58 | workExperiences: "WORK EXPERIENCE",
59 | educations: "EDUCATION",
60 | projects: "PROJECT",
61 | skills: "SKILLS",
62 | custom: "CUSTOM SECTION",
63 | },
64 | formsOrder: ["workExperiences", "educations", "projects", "skills", "custom"],
65 | showBulletPoints: {
66 | educations: true,
67 | projects: true,
68 | skills: true,
69 | custom: true,
70 | },
71 | };
72 |
73 | export const settingsSlice = createSlice({
74 | name: "settings",
75 | initialState: initialSettings,
76 | reducers: {
77 | changeSettings: (
78 | draft,
79 | action: PayloadAction<{ field: GeneralSetting; value: string }>
80 | ) => {
81 | const { field, value } = action.payload;
82 | draft[field] = value;
83 | },
84 | changeShowForm: (
85 | draft,
86 | action: PayloadAction<{ field: ShowForm; value: boolean }>
87 | ) => {
88 | const { field, value } = action.payload;
89 | draft.formToShow[field] = value;
90 | },
91 | changeFormHeading: (
92 | draft,
93 | action: PayloadAction<{ field: ShowForm; value: string }>
94 | ) => {
95 | const { field, value } = action.payload;
96 | draft.formToHeading[field] = value;
97 | },
98 | changeFormOrder: (
99 | draft,
100 | action: PayloadAction<{ form: ShowForm; type: "up" | "down" }>
101 | ) => {
102 | const { form, type } = action.payload;
103 | const lastIdx = draft.formsOrder.length - 1;
104 | const pos = draft.formsOrder.indexOf(form);
105 | const newPos = type === "up" ? pos - 1 : pos + 1;
106 | const swapFormOrder = (idx1: number, idx2: number) => {
107 | const temp = draft.formsOrder[idx1];
108 | draft.formsOrder[idx1] = draft.formsOrder[idx2];
109 | draft.formsOrder[idx2] = temp;
110 | };
111 | if (newPos >= 0 && newPos <= lastIdx) {
112 | swapFormOrder(pos, newPos);
113 | }
114 | },
115 | changeShowBulletPoints: (
116 | draft,
117 | action: PayloadAction<{ field: FormWithBulletPoints; value: boolean }>
118 | ) => {
119 | const { field, value } = action.payload;
120 | draft["showBulletPoints"][field] = value;
121 | },
122 | setSettings: (draft, action: PayloadAction) => {
123 | return action.payload;
124 | },
125 | },
126 | });
127 |
128 | export const {
129 | changeSettings,
130 | changeShowForm,
131 | changeFormHeading,
132 | changeFormOrder,
133 | changeShowBulletPoints,
134 | setSettings,
135 | } = settingsSlice.actions;
136 |
137 | export const selectSettings = (state: RootState) => state.settings;
138 | export const selectThemeColor = (state: RootState) => state.settings.themeColor;
139 |
140 | export const selectFormToShow = (state: RootState) => state.settings.formToShow;
141 | export const selectShowByForm = (form: ShowForm) => (state: RootState) =>
142 | state.settings.formToShow[form];
143 |
144 | export const selectFormToHeading = (state: RootState) =>
145 | state.settings.formToHeading;
146 | export const selectHeadingByForm = (form: ShowForm) => (state: RootState) =>
147 | state.settings.formToHeading[form];
148 |
149 | export const selectFormsOrder = (state: RootState) => state.settings.formsOrder;
150 | export const selectIsFirstForm = (form: ShowForm) => (state: RootState) =>
151 | state.settings.formsOrder[0] === form;
152 | export const selectIsLastForm = (form: ShowForm) => (state: RootState) =>
153 | state.settings.formsOrder[state.settings.formsOrder.length - 1] === form;
154 |
155 | export const selectShowBulletPoints =
156 | (form: FormWithBulletPoints) => (state: RootState) =>
157 | state.settings.showBulletPoints[form];
158 |
159 | export default settingsSlice.reducer;
160 |
--------------------------------------------------------------------------------
/app/lib/redux/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import resumeReducer from "./resumeSlice";
3 | import settingsReducer from "./settingsSlice";
4 |
5 | export const store = configureStore({
6 | reducer: {
7 | resume: resumeReducer,
8 | settings: settingsReducer,
9 | },
10 | });
11 |
12 | export type RootState = ReturnType;
13 | export type AppDispatch = typeof store.dispatch;
14 |
--------------------------------------------------------------------------------
/app/lib/redux/types.ts:
--------------------------------------------------------------------------------
1 | export interface ResumeProfile {
2 | name: string;
3 | email: string;
4 | phone: string;
5 | url: string;
6 | summary: string;
7 | location: string;
8 | }
9 |
10 | export interface ResumeWorkExperience {
11 | company: string;
12 | jobTitle: string;
13 | date: string;
14 | descriptions: string[];
15 | }
16 |
17 | export interface ResumeEducation {
18 | school: string;
19 | degree: string;
20 | date: string;
21 | gpa: string;
22 | descriptions: string[];
23 | }
24 |
25 | export interface ResumeProject {
26 | project: string;
27 | date: string;
28 | descriptions: string[];
29 | }
30 |
31 | export interface FeaturedSkill {
32 | skill: string;
33 | rating: number;
34 | }
35 |
36 | export interface ResumeSkills {
37 | featuredSkills: FeaturedSkill[];
38 | descriptions: string[];
39 | }
40 |
41 | export interface ResumeCustom {
42 | descriptions: string[];
43 | }
44 |
45 | export interface Resume {
46 | profile: ResumeProfile;
47 | workExperiences: ResumeWorkExperience[];
48 | educations: ResumeEducation[];
49 | projects: ResumeProject[];
50 | skills: ResumeSkills;
51 | custom: ResumeCustom;
52 | }
53 |
54 | export type ResumeKey = keyof Resume;
55 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { Hero } from "./home/Hero";
3 | import { Steps } from "./home/Steps";
4 |
5 | export default function Home() {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/app/resume-builder/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Provider } from "react-redux";
4 | import { store } from "../lib/redux/store";
5 | import { ResumeForm } from "../components/ResumeForm";
6 | import { Resume } from "../components/Resume";
7 |
8 | export default function Create() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/resume-import/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { getHasUsedAppBefore } from "../lib/redux/local-storage";
5 | import Link from "next/link";
6 | import ResumeDropzone from "../components/ResumeDropzone";
7 |
8 | export default function ImportResume() {
9 | const [hasUsedAppBefore, setHasUsedAppBefore] = useState(false);
10 | const [hasAddedResume, setHasAddedResume] = useState(false);
11 |
12 | const onFileUrlChange = (fileUrl: string) => {
13 | setHasAddedResume(Boolean(fileUrl));
14 | };
15 |
16 | useEffect(() => {
17 | setHasUsedAppBefore(getHasUsedAppBefore());
18 | }, []);
19 |
20 | return (
21 |
22 |
23 | {!hasUsedAppBefore ? (
24 | <>
25 |
26 | Import data from an existing resume
27 |
28 |
32 | {!hasAddedResume && (
33 | <>
34 |
35 |
39 | >
40 | )}
41 | >
42 | ) : (
43 | <>
44 | {!hasAddedResume && (
45 | <>
46 |
50 |
51 | >
52 | )}
53 |
54 | Override data with a new resume
55 |
56 |
60 | >
61 | )}
62 |
63 |
64 | );
65 | }
66 |
67 | const OrDivider = () => (
68 |
73 | );
74 |
75 | const SectionWithHeadingAndCreateButton = ({
76 | heading,
77 | buttonText,
78 | }: {
79 | heading: string;
80 | buttonText: string;
81 | }) => {
82 | return (
83 | <>
84 | {heading}
85 |
86 |
90 | {buttonText}
91 |
92 |
93 | >
94 | );
95 | };
96 |
--------------------------------------------------------------------------------
/app/resume-parser/ResumeParserAlgorithmArticle.tsx:
--------------------------------------------------------------------------------
1 | export const ResumeParserAlgorithmArticle = () => {
2 | return <>>;
3 | };
4 |
--------------------------------------------------------------------------------
/app/resume-parser/ResumeTable.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from "react";
2 | import { cx } from "../lib/cx";
3 | import { deepClone } from "../lib/parse-resume-from-pdf/deep-clone";
4 | import {
5 | initialEducation,
6 | initialWorkExperience,
7 | } from "../lib/redux/resumeSlice";
8 | import { Resume } from "../lib/redux/types";
9 |
10 | const TableRowHeader = ({ children }: { children: React.ReactNode }) => (
11 |
12 |
13 | {children}
14 |
15 |
16 | );
17 |
18 | const TableRow = ({
19 | label,
20 | value,
21 | className,
22 | }: {
23 | label: string;
24 | value: string | string[];
25 | className?: string | false;
26 | }) => (
27 |
28 |
29 | {label}
30 |
31 |
32 | {typeof value === "string"
33 | ? value
34 | : value.map((x, idx) => • {x} )}
35 |
36 |
37 | );
38 |
39 | export const ResumeTable = ({ resume }: { resume: Resume }) => {
40 | const educations =
41 | resume.educations.length === 0
42 | ? [deepClone(initialEducation)]
43 | : resume.educations;
44 | const workExperiences =
45 | resume.workExperiences.length === 0
46 | ? [deepClone(initialWorkExperience)]
47 | : resume.workExperiences;
48 | const skills = [...resume.skills.descriptions];
49 | const featuredSkills = resume.skills.featuredSkills
50 | .filter((item) => item.skill.trim())
51 | .map((item) => item.skill)
52 | .join(", ")
53 | .trim();
54 | if (featuredSkills) {
55 | skills.unshift(featuredSkills);
56 | }
57 |
58 | return (
59 |
60 |
61 | Profile
62 |
63 |
64 |
65 |
66 |
67 |
68 | Education
69 | {educations.map((education, idx) => (
70 |
71 |
72 |
73 |
74 |
75 |
84 |
85 | ))}
86 | Work Experience
87 | {workExperiences.map((workExperience, idx) => (
88 |
89 |
90 |
91 |
92 |
101 |
102 | ))}
103 | {resume.projects.length > 0 && (
104 | Projects
105 | )}
106 | {resume.projects.map((project, idx) => (
107 |
108 |
109 |
110 |
119 |
120 | ))}
121 | Skills
122 |
123 |
124 |
125 | );
126 | };
127 |
--------------------------------------------------------------------------------
/app/resume-parser/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { useEffect, useState } from "react";
5 | import { TextItems } from "../lib/parse-resume-from-pdf/types";
6 | import { groupTextItemsIntoLines } from "../lib/parse-resume-from-pdf/group-text-items-into-lines";
7 | import { groupLinesIntoSections } from "../lib/parse-resume-from-pdf/group-lines-into-sections";
8 | import { extractResumeFromSections } from "../lib/parse-resume-from-pdf/extract-resume-from-sections";
9 | import { FlexboxSpacer } from "../components/FlexboxSpacer";
10 | import { Heading } from "../components/documentation/Heading";
11 | import { Paragraph } from "../components/documentation/Paragraph";
12 | import { cx } from "../lib/cx";
13 | import ResumeDropzone from "../components/ResumeDropzone";
14 | import { ResumeTable } from "./ResumeTable";
15 | import { readPdf } from "../lib/parse-resume-from-pdf/read-pdf";
16 |
17 | const RESUME_EXAMPLES = [
18 | {
19 | fileUrl: "resume-example/public-resume.pdf",
20 | description: Took from public sources ,
21 | },
22 | {
23 | fileUrl: "resume-example/inhouse-resume.pdf",
24 | description: (
25 |
26 | Created with Inhouse Resume Builder -{" "}
27 | Link
28 |
29 | ),
30 | },
31 | ];
32 |
33 | const defaultFileUrl = RESUME_EXAMPLES[1]["fileUrl"];
34 |
35 | export default function ResumeParser() {
36 | const [fileUrl, setFileUrl] = useState(defaultFileUrl);
37 |
38 | const [textItems, setTextItems] = useState([]);
39 | const lines = groupTextItemsIntoLines(textItems || []);
40 | const sections = groupLinesIntoSections(lines);
41 | const resume = extractResumeFromSections(sections);
42 |
43 | useEffect(() => {
44 | async function parse() {
45 | const textItems = await readPdf(fileUrl);
46 | setTextItems(textItems);
47 | }
48 | parse();
49 | }, [fileUrl]);
50 |
51 | return (
52 |
53 |
54 |
62 |
63 |
64 |
65 |
66 | Resume Parser Playground
67 |
68 |
69 | This playground showcases the Inhouse resume parser and its
70 | ability to parse information from a resume PDF. Click around the
71 | PDF examples below to observe different parsing results.
72 |
73 |
74 | {RESUME_EXAMPLES.map((example, idx) => (
75 |
setFileUrl(example.fileUrl)}
84 | onKeyDown={(e) => {
85 | if (["Enter", " "].includes(e.key))
86 | setFileUrl(example.fileUrl);
87 | }}
88 | tabIndex={0}
89 | >
90 | Resume Example {idx + 1}
91 |
92 | {example.description}
93 |
94 |
95 | ))}
96 |
97 |
98 | You can also{" "}
99 | add your resume below to
100 | access how well your resume would be parsed by similar Application
101 | Tracking System (ATS) used in job applications. The more
102 | information it can parse out, the better it indicates the resume
103 | is well formatted and easy to read.
104 |
105 |
106 |
108 | setFileUrl(fileUrl || defaultFileUrl)
109 | }
110 | playgroundView={true}
111 | />
112 |
113 |
114 | Resume Parsing Results
115 |
116 |
117 |
118 |
119 |
120 |
121 | );
122 | }
123 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | output: "standalone",
4 | webpack: (config) => {
5 | config.resolve.alias.canvas = false;
6 | config.resolve.alias.encoding = false;
7 | return config;
8 | },
9 | };
10 |
11 | module.exports = nextConfig;
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "resumebuilderparser",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@heroicons/react": "^2.1.1",
13 | "@react-pdf/renderer": "^3.1.14",
14 | "@reduxjs/toolkit": "^2.0.1",
15 | "@tailwindcss/aspect-ratio": "^0.4.2",
16 | "next": "14.0.4",
17 | "pdfjs": "^2.5.0",
18 | "pdfjs-dist": "^3.7.107",
19 | "react": "^18",
20 | "react-contenteditable": "^3.3.7",
21 | "react-dom": "^18",
22 | "react-frame-component": "^5.2.6",
23 | "react-redux": "^9.0.4",
24 | "tailwind-scrollbar": "^3.0.5"
25 | },
26 | "devDependencies": {
27 | "@types/node": "^20",
28 | "@types/react": "^18",
29 | "@types/react-dom": "^18",
30 | "autoprefixer": "^10.0.1",
31 | "eslint": "^8",
32 | "eslint-config-next": "14.0.4",
33 | "postcss": "^8",
34 | "tailwindcss": "^3.3.0",
35 | "typescript": "^5"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/assets/add-pdf.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/public/assets/dots.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/assets/heart.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/fonts/Caladea-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Caladea-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/Caladea-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Caladea-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Lato-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Lato-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/Lato-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Lato-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Lora-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Lora-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/Lora-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Lora-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Merriweather-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Merriweather-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/Merriweather-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Merriweather-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Montserrat-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Montserrat-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/Montserrat-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Montserrat-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright (c), Christian Robertson (https://fonts.google.com/specimen/Roboto/about?query=Roboto),
2 | with Reserved Font Name Roboto.
3 |
4 | Copyright (c), Łukasz Dziedzic (https://fonts.google.com/specimen/Lato?query=lato),
5 | with Reserved Font Name Lato.
6 |
7 | Copyright (c), Julieta Ulanovsky, Sol Matas, Juan Pablo del Peral, Jacques Le Bailly (https://fonts.google.com/specimen/Montserrat?query=Montserrat),
8 | with Reserved Font Name Montserrat.
9 |
10 | Copyright (c), Steve Matteson (https://fonts.google.com/specimen/Open+Sans?query=Open+Sans),
11 | with Reserved Font Name Open Sans.
12 |
13 | Copyright (c), Matt McInerney, Pablo Impallari, Rodrigo Fuenzalida (https://fonts.google.com/specimen/Raleway?query=Raleway),
14 | with Reserved Font Name Raleway.
15 |
16 | Copyright (c), Andrés Torresi, Carolina Giovanolli (https://fonts.google.com/specimen/Caladea?query=Caladea),
17 | with Reserved Font Name Caladea.
18 |
19 | Copyright (c), Cyreal (https://fonts.google.com/specimen/Lora?query=Lora),
20 | with Reserved Font Name Lora.
21 |
22 | Copyright (c), Christian Robertson (https://fonts.google.com/specimen/Roboto+Slab/about?query=Roboto+Slab),
23 | with Reserved Font Name Roboto Slab.
24 |
25 | Copyright (c), Claus Eggers Sørensen (https://fonts.google.com/specimen/Playfair+Display?query=Playfair+Display),
26 | with Reserved Font Name Playfair Display.
27 |
28 | Copyright (c), Sorkin Type (https://fonts.google.com/specimen/Merriweather/about?query=Merriweather),
29 | with Reserved Font Name Merriweather.
30 |
31 | Copyright (c), (https://fonts.google.com/noto/specimen/Noto+Sans+SC?query=Noto+Sans+SC¬o.query=Noto+Sans+SC),
32 | with Reserved Font Name Noto Sans Simplified Chinese.
33 |
34 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
35 | This license is copied below, and is also available with a FAQ at:
36 | http://scripts.sil.org/OFL
37 |
38 |
39 | -----------------------------------------------------------
40 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
41 | -----------------------------------------------------------
42 |
43 | PREAMBLE
44 | The goals of the Open Font License (OFL) are to stimulate worldwide
45 | development of collaborative font projects, to support the font creation
46 | efforts of academic and linguistic communities, and to provide a free and
47 | open framework in which fonts may be shared and improved in partnership
48 | with others.
49 |
50 | The OFL allows the licensed fonts to be used, studied, modified and
51 | redistributed freely as long as they are not sold by themselves. The
52 | fonts, including any derivative works, can be bundled, embedded,
53 | redistributed and/or sold with any software provided that any reserved
54 | names are not used by derivative works. The fonts and derivatives,
55 | however, cannot be released under any other type of license. The
56 | requirement for fonts to remain under this license does not apply
57 | to any document created using the fonts or their derivatives.
58 |
59 | DEFINITIONS
60 | "Font Software" refers to the set of files released by the Copyright
61 | Holder(s) under this license and clearly marked as such. This may
62 | include source files, build scripts and documentation.
63 |
64 | "Reserved Font Name" refers to any names specified as such after the
65 | copyright statement(s).
66 |
67 | "Original Version" refers to the collection of Font Software components as
68 | distributed by the Copyright Holder(s).
69 |
70 | "Modified Version" refers to any derivative made by adding to, deleting,
71 | or substituting -- in part or in whole -- any of the components of the
72 | Original Version, by changing formats or by porting the Font Software to a
73 | new environment.
74 |
75 | "Author" refers to any designer, engineer, programmer, technical
76 | writer or other person who contributed to the Font Software.
77 |
78 | PERMISSION & CONDITIONS
79 | Permission is hereby granted, free of charge, to any person obtaining
80 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
81 | redistribute, and sell modified and unmodified copies of the Font
82 | Software, subject to the following conditions:
83 |
84 | 1) Neither the Font Software nor any of its individual components,
85 | in Original or Modified Versions, may be sold by itself.
86 |
87 | 2) Original or Modified Versions of the Font Software may be bundled,
88 | redistributed and/or sold with any software, provided that each copy
89 | contains the above copyright notice and this license. These can be
90 | included either as stand-alone text files, human-readable headers or
91 | in the appropriate machine-readable metadata fields within text or
92 | binary files as long as those fields can be easily viewed by the user.
93 |
94 | 3) No Modified Version of the Font Software may use the Reserved Font
95 | Name(s) unless explicit written permission is granted by the corresponding
96 | Copyright Holder. This restriction only applies to the primary font name as
97 | presented to the users.
98 |
99 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
100 | Software shall not be used to promote, endorse or advertise any
101 | Modified Version, except to acknowledge the contribution(s) of the
102 | Copyright Holder(s) and the Author(s) or with their explicit written
103 | permission.
104 |
105 | 5) The Font Software, modified or unmodified, in part or in whole,
106 | must be distributed entirely under this license, and must not be
107 | distributed under any other license. The requirement for fonts to
108 | remain under this license does not apply to any document created
109 | using the Font Software.
110 |
111 | TERMINATION
112 | This license becomes null and void if any of the above conditions are
113 | not met.
114 |
115 | DISCLAIMER
116 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
117 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
118 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
119 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
120 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
121 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
122 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
123 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
124 | OTHER DEALINGS IN THE FONT SOFTWARE.
125 |
--------------------------------------------------------------------------------
/public/fonts/OpenSans-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/OpenSans-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/OpenSans-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/OpenSans-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/PlayfairDisplay-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/PlayfairDisplay-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/PlayfairDisplay-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/PlayfairDisplay-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Raleway-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Raleway-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/Raleway-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Raleway-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Roboto-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Roboto-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/Roboto-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/Roboto-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/RobotoSlab-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/RobotoSlab-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/RobotoSlab-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/fonts/RobotoSlab-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/fonts.css:
--------------------------------------------------------------------------------
1 | /* Adding a new font family needs to keep "public\fonts\fonts.ts" in sync */
2 | /* Sans Serif Fonts */
3 | @font-face {
4 | font-family: "Roboto";
5 | src: url("/fonts/Roboto-Regular.ttf");
6 | }
7 | @font-face {
8 | font-family: "Roboto";
9 | src: url("/fonts/Roboto-Bold.ttf");
10 | font-weight: bold;
11 | }
12 | @font-face {
13 | font-family: "Lato";
14 | src: url("/fonts/Lato-Regular.ttf");
15 | }
16 | @font-face {
17 | font-family: "Lato";
18 | src: url("/fonts/Lato-Bold.ttf");
19 | font-weight: bold;
20 | }
21 | @font-face {
22 | font-family: "Montserrat";
23 | src: url("/fonts/Montserrat-Regular.ttf");
24 | }
25 | @font-face {
26 | font-family: "Montserrat";
27 | src: url("/fonts/Montserrat-Bold.ttf");
28 | font-weight: bold;
29 | }
30 | @font-face {
31 | font-family: "OpenSans";
32 | src: url("/fonts/OpenSans-Regular.ttf");
33 | }
34 | @font-face {
35 | font-family: "OpenSans";
36 | src: url("/fonts/OpenSans-Bold.ttf");
37 | font-weight: bold;
38 | }
39 | @font-face {
40 | font-family: "Raleway";
41 | src: url("/fonts/Raleway-Regular.ttf");
42 | }
43 | @font-face {
44 | font-family: "Raleway";
45 | src: url("/fonts/Raleway-Bold.ttf");
46 | font-weight: bold;
47 | }
48 |
49 | /* Serif Fonts */
50 | @font-face {
51 | font-family: "Caladea";
52 | src: url("/fonts/Caladea-Regular.ttf");
53 | }
54 | @font-face {
55 | font-family: "Caladea";
56 | src: url("/fonts/Caladea-Bold.ttf");
57 | font-weight: bold;
58 | }
59 | @font-face {
60 | font-family: "Lora";
61 | src: url("/fonts/Lora-Regular.ttf");
62 | }
63 | @font-face {
64 | font-family: "Lora";
65 | src: url("/fonts/Lora-Bold.ttf");
66 | font-weight: bold;
67 | }
68 | @font-face {
69 | font-family: "RobotoSlab";
70 | src: url("/fonts/RobotoSlab-Regular.ttf");
71 | }
72 | @font-face {
73 | font-family: "RobotoSlab";
74 | src: url("/fonts/RobotoSlab-Bold.ttf");
75 | font-weight: bold;
76 | }
77 | @font-face {
78 | font-family: "PlayfairDisplay";
79 | src: url("/fonts/PlayfairDisplay-Regular.ttf");
80 | }
81 | @font-face {
82 | font-family: "PlayfairDisplay";
83 | src: url("/fonts/PlayfairDisplay-Bold.ttf");
84 | font-weight: bold;
85 | }
86 | @font-face {
87 | font-family: "Merriweather";
88 | src: url("/fonts/Merriweather-Regular.ttf");
89 | }
90 | @font-face {
91 | font-family: "Merriweather";
92 | src: url("/fonts/Merriweather-Bold.ttf");
93 | font-weight: bold;
94 | }
95 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/resume-example/inhouse-resume.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/resume-example/inhouse-resume.pdf
--------------------------------------------------------------------------------
/public/resume-example/public-resume.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuluruvineeth/resumebuilderparser/91380e62567f002e66331478ef1238656d7c0853/public/resume-example/public-resume.pdf
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | // "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | // "gradient-conic":
14 | // "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | dot: "url('/assets/dots.svg')",
16 | },
17 | },
18 | },
19 | corePlugins: {
20 | aspectRatio: false,
21 | },
22 | plugins: [
23 | require("@tailwindcss/aspect-ratio"),
24 | require("tailwind-scrollbar")({ nocompatible: true }),
25 | ],
26 | };
27 | export default config;
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------